diff --git a/config.example.toml b/config.example.toml index ad0c5340..77c83311 100644 --- a/config.example.toml +++ b/config.example.toml @@ -135,7 +135,7 @@ validator_pubkeys = [ # OPTIONAL loader = "./tests/data/mux_keys.example.json" # loader = { url = "http://localhost:8000/keys" } -# loader = { registry = "lido", node_operator_id = 8 } +# loader = { registry = "lido", node_operator_id = 8, lido_module_id = 1 } # loader = { registry = "ssv", node_operator_id = 8 } late_in_slot_time_ms = 1500 timeout_get_header_ms = 900 diff --git a/crates/common/src/abi/LidoCSModuleNORegistry.json b/crates/common/src/abi/LidoCSModuleNORegistry.json new file mode 100644 index 00000000..a0b98aab --- /dev/null +++ b/crates/common/src/abi/LidoCSModuleNORegistry.json @@ -0,0 +1,37 @@ +[ + { + "constant": true, + "inputs": [ + { "name": "nodeOperatorId", "type": "uint256" } + ], + "name": "getNodeOperatorSummary", + "outputs": [ + { "name": "targetLimitMode", "type": "uint256" }, + { "name": "targetValidatorsCount", "type": "uint256" }, + { "name": "stuckValidatorsCount", "type": "uint256" }, + { "name": "refundedValidatorsCount", "type": "uint256" }, + { "name": "stuckPenaltyEndTimestamp", "type": "uint256" }, + { "name": "totalExitedValidators", "type": "uint256" }, + { "name": "totalDepositedValidators", "type": "uint256" }, + { "name": "depositableValidatorsCount", "type": "uint256" } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { "name": "nodeOperatorId", "type": "uint256" }, + { "name": "startIndex", "type": "uint256" }, + { "name": "keysCount", "type": "uint256" } + ], + "name": "getSigningKeys", + "outputs": [ + { "name": "", "type": "bytes" } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + } +] diff --git a/crates/common/src/config/mux.rs b/crates/common/src/config/mux.rs index 346eaf78..89b951c0 100644 --- a/crates/common/src/config/mux.rs +++ b/crates/common/src/config/mux.rs @@ -6,23 +6,24 @@ use std::{ }; use alloy::{ - primitives::{Address, U256, address}, + primitives::{address, Address, Bytes, U256}, providers::ProviderBuilder, rpc::{client::RpcClient, types::beacon::constants::BLS_PUBLIC_KEY_BYTES_LEN}, sol, transports::http::Http, }; -use eyre::{Context, bail, ensure}; +use eyre::{bail, ensure, Context}; use reqwest::Client; use serde::{Deserialize, Deserializer, Serialize}; use tracing::{debug, info, warn}; use url::Url; +use LidoCSMRegistry::getNodeOperatorSummaryReturn; use super::{MUX_PATH_ENV, PbsConfig, RelayConfig, load_optional_env_var}; use crate::{ config::{remove_duplicate_keys, safe_read_http_response}, pbs::RelayClient, - types::{BlsPublicKey, Chain}, + types::{BlsPublicKey, Chain, HoleskyLidoModule, HoodiLidoModule, MainnetLidoModule}, }; #[derive(Debug, Deserialize, Serialize)] @@ -167,6 +168,8 @@ pub enum MuxKeysLoader { Registry { registry: NORegistry, node_operator_id: u64, + #[serde(default)] + lido_module_id: Option }, } @@ -210,7 +213,7 @@ impl MuxKeysLoader { .wrap_err("failed to fetch mux keys from HTTP endpoint") } - Self::Registry { registry, node_operator_id } => match registry { + Self::Registry { registry, node_operator_id, lido_module_id } => match registry { NORegistry::Lido => { let Some(rpc_url) = rpc_url else { bail!("Lido registry requires RPC URL to be set in the PBS config"); @@ -220,6 +223,7 @@ impl MuxKeysLoader { rpc_url, chain, U256::from(*node_operator_id), + lido_module_id.unwrap_or(1), http_timeout, ) .await @@ -257,56 +261,182 @@ sol! { "src/abi/LidoNORegistry.json" } -// Fetching Lido Curated Module -fn lido_registry_address(chain: Chain) -> eyre::Result
{ +sol! { + #[allow(missing_docs)] + #[sol(rpc)] + LidoCSMRegistry, + "src/abi/LidoCSModuleNORegistry.json" +} + +fn lido_registry_addresses_by_module() -> HashMap> { + let mut map: HashMap> = HashMap::new(); + + // --- Mainnet --- + let mut mainnet = HashMap::new(); + mainnet.insert(MainnetLidoModule::Curated as u8, address!("55032650b14df07b85bF18A3a3eC8E0Af2e028d5")); + mainnet.insert(MainnetLidoModule::SimpleDVT as u8, address!("aE7B191A31f627b4eB1d4DaC64eaB9976995b433")); + mainnet.insert(MainnetLidoModule::CommunityStaking as u8, address!("dA7dE2ECdDfccC6c3AF10108Db212ACBBf9EA83F")); + map.insert(Chain::Mainnet, mainnet); + + // --- Holesky --- + let mut holesky = HashMap::new(); + holesky.insert(HoleskyLidoModule::Curated as u8, address!("595F64Ddc3856a3b5Ff4f4CC1d1fb4B46cFd2bAC")); + holesky.insert(HoleskyLidoModule::SimpleDVT as u8, address!("11a93807078f8BB880c1BD0ee4C387537de4b4b6")); + holesky.insert(HoleskyLidoModule::Sandbox as u8, address!("D6C2ce3BB8bea2832496Ac8b5144819719f343AC")); + holesky.insert(HoleskyLidoModule::CommunityStaking as u8, address!("4562c3e63c2e586cD1651B958C22F88135aCAd4f")); + map.insert(Chain::Holesky, holesky); + + // --- Hoodi --- + let mut hoodi = HashMap::new(); + hoodi.insert(HoodiLidoModule::Curated as u8, address!("5cDbE1590c083b5A2A64427fAA63A7cfDB91FbB5")); + hoodi.insert(HoodiLidoModule::SimpleDVT as u8, address!("0B5236BECA68004DB89434462DfC3BB074d2c830")); + hoodi.insert(HoodiLidoModule::Sandbox as u8, address!("682E94d2630846a503BDeE8b6810DF71C9806891")); + hoodi.insert(HoodiLidoModule::CommunityStaking as u8, address!("79CEf36D84743222f37765204Bec41E92a93E59d")); + map.insert(Chain::Hoodi, hoodi); + + // --- Sepolia -- + let mut sepolia = HashMap::new(); + sepolia.insert(1, address!("33d6E15047E8644F8DDf5CD05d202dfE587DA6E3")); + map.insert(Chain::Sepolia, sepolia); + + map +} + +// Fetching appropiate registry address +fn lido_registry_address(chain: Chain, lido_module_id: u8) -> eyre::Result
{ + lido_registry_addresses_by_module() + .get(&chain) + .ok_or_else(|| eyre::eyre!("Lido registry not supported for chain: {chain:?}"))? + .get(&lido_module_id) + .copied() + .ok_or_else(|| eyre::eyre!( + "Lido module id {:?} not found for chain: {chain:?}", + lido_module_id + )) +} + +fn is_csm_module(chain: Chain, module_id: u8) -> bool { match chain { - Chain::Mainnet => Ok(address!("55032650b14df07b85bF18A3a3eC8E0Af2e028d5")), - Chain::Holesky => Ok(address!("595F64Ddc3856a3b5Ff4f4CC1d1fb4B46cFd2bAC")), - Chain::Hoodi => Ok(address!("5cDbE1590c083b5A2A64427fAA63A7cfDB91FbB5")), - Chain::Sepolia => Ok(address!("33d6E15047E8644F8DDf5CD05d202dfE587DA6E3")), - _ => bail!("Lido registry not supported for chain: {chain:?}"), + Chain::Mainnet => module_id == MainnetLidoModule::CommunityStaking as u8, + Chain::Holesky => module_id == HoleskyLidoModule::CommunityStaking as u8, + Chain::Hoodi => module_id == HoodiLidoModule::CommunityStaking as u8, + _ => false, } } -async fn fetch_lido_registry_keys( - rpc_url: Url, - chain: Chain, +fn get_lido_csm_registry

( + registry_address: Address, + provider: P, +) -> LidoCSMRegistry::LidoCSMRegistryInstance

+where + P: Clone + Send + Sync + 'static + alloy::providers::Provider, +{ + LidoCSMRegistry::new(registry_address, provider) +} + +fn get_lido_module_registry

( + registry_address: Address, + provider: P, +) -> LidoRegistry::LidoRegistryInstance

+where + P: Clone + Send + Sync + 'static + alloy::providers::Provider, +{ + LidoRegistry::new(registry_address, provider) +} + +async fn fetch_lido_csm_keys_total

( + registry: &LidoCSMRegistry::LidoCSMRegistryInstance

, node_operator_id: U256, - http_timeout: Duration, -) -> eyre::Result> { - debug!(?chain, %node_operator_id, "loading operator keys from Lido registry"); +) -> eyre::Result +where + P: Clone + Send + Sync + 'static + alloy::providers::Provider, +{ + let summary: getNodeOperatorSummaryReturn = registry + .getNodeOperatorSummary(node_operator_id) + .call() + .await?; - // Create an RPC provider with HTTP timeout support - let client = Client::builder().timeout(http_timeout).build()?; - let http = Http::with_client(client, rpc_url); - let is_local = http.guess_local(); - let rpc_client = RpcClient::new(http, is_local); - let provider = ProviderBuilder::new().connect_client(rpc_client); + let total_u256 = summary.totalDepositedValidators + summary.depositableValidatorsCount; + + let total_u64 = u64::try_from(total_u256) + .wrap_err_with(|| format!("total keys ({total_u256}) does not fit into u64"))?; + + Ok(total_u64) +} + +async fn fetch_lido_module_keys_total

( + registry: &LidoRegistry::LidoRegistryInstance

, + node_operator_id: U256, +) -> eyre::Result +where + P: Clone + Send + Sync + 'static + alloy::providers::Provider, +{ + let total_keys: u64 = registry + .getTotalSigningKeyCount(node_operator_id) + .call() + .await? + .try_into()?; + + Ok(total_keys) +} + +async fn fetch_lido_csm_keys_batch

( + registry: &LidoCSMRegistry::LidoCSMRegistryInstance

, + node_operator_id: U256, + offset: u64, + limit: u64 +) -> eyre::Result +where + P: Clone + Send + Sync + 'static + alloy::providers::Provider, +{ + let pubkeys = registry + .getSigningKeys(node_operator_id, U256::from(offset), U256::from(limit)) + .call() + .await?; + + Ok(pubkeys) +} - let registry_address = lido_registry_address(chain)?; - let registry = LidoRegistry::new(registry_address, provider); +async fn fetch_lido_module_keys_batch

( + registry: &LidoRegistry::LidoRegistryInstance

, + node_operator_id: U256, + offset: u64, + limit: u64 +) -> eyre::Result +where + P: Clone + Send + Sync + 'static + alloy::providers::Provider, +{ + let pubkeys = registry + .getSigningKeys(node_operator_id, U256::from(offset), U256::from(limit)) + .call() + .await? + .pubkeys; - let total_keys = registry.getTotalSigningKeyCount(node_operator_id).call().await?.try_into()?; + Ok(pubkeys) +} +async fn collect_registry_keys( + total_keys: u64, + mut fetch_batch: F, +) -> eyre::Result> +where + F: FnMut(u64, u64) -> Fut, + Fut: std::future::Future>, +{ if total_keys == 0 { return Ok(Vec::new()); } - debug!("fetching {total_keys} total keys"); const CALL_BATCH_SIZE: u64 = 250u64; let mut keys = vec![]; - let mut offset = 0; + let mut offset: u64 = 0; while offset < total_keys { let limit = CALL_BATCH_SIZE.min(total_keys - offset); - let pubkeys = registry - .getSigningKeys(node_operator_id, U256::from(offset), U256::from(limit)) - .call() - .await? - .pubkeys; + let pubkeys = fetch_batch(offset, limit).await?; ensure!( pubkeys.len() % BLS_PUBLIC_KEY_BYTES_LEN == 0, @@ -333,6 +463,58 @@ async fn fetch_lido_registry_keys( Ok(keys) } +async fn fetch_lido_csm_registry_keys ( + registry_address: Address, + rpc_client: RpcClient, + node_operator_id: U256, +) -> eyre::Result> { + let provider = ProviderBuilder::new().connect_client(rpc_client); + let registry = get_lido_csm_registry(registry_address, provider); + + let total_keys = fetch_lido_csm_keys_total(®istry, node_operator_id).await?.try_into()?; + + collect_registry_keys(total_keys, |offset, limit| { + fetch_lido_csm_keys_batch(®istry, node_operator_id, offset, limit) + }).await +} + +async fn fetch_lido_module_registry_keys ( + registry_address: Address, + rpc_client: RpcClient, + node_operator_id: U256, +) -> eyre::Result> { + let provider = ProviderBuilder::new().connect_client(rpc_client); + let registry = get_lido_module_registry(registry_address, provider); + let total_keys: u64 = fetch_lido_module_keys_total(®istry, node_operator_id).await?.try_into()?; + + collect_registry_keys(total_keys, |offset, limit| { + fetch_lido_module_keys_batch(®istry, node_operator_id, offset, limit) + }).await +} + +async fn fetch_lido_registry_keys( + rpc_url: Url, + chain: Chain, + node_operator_id: U256, + lido_module_id: u8, + http_timeout: Duration, +) -> eyre::Result> { + debug!(?chain, %node_operator_id, ?lido_module_id, "loading operator keys from Lido registry"); + + // Create an RPC provider with HTTP timeout support + let client = Client::builder().timeout(http_timeout).build()?; + let http = Http::with_client(client, rpc_url); + let is_local = http.guess_local(); + 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) { + 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 + } +} + async fn fetch_ssv_pubkeys( chain: Chain, node_operator_id: U256, @@ -480,6 +662,49 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_lido_csm_registry_address() -> eyre::Result<()> { + use alloy::{primitives::U256, providers::ProviderBuilder}; + + let url = Url::parse("https://ethereum-rpc.publicnode.com")?; + let provider = ProviderBuilder::new().connect_http(url); + + let registry = LidoCSMRegistry::new( + address!("dA7dE2ECdDfccC6c3AF10108Db212ACBBf9EA83F"), + provider, + ); + + const LIMIT: usize = 3; + let node_operator_id = U256::from(1); + + let summary = registry + .getNodeOperatorSummary(node_operator_id) + .call() + .await?; + + let total_keys_u256 = summary.totalDepositedValidators + summary.depositableValidatorsCount; + let total_keys: u64 = total_keys_u256.try_into()?; + + assert!(total_keys > LIMIT as u64, "expected more than {LIMIT} keys, got {total_keys}"); + + let pubkeys = registry + .getSigningKeys(node_operator_id, U256::ZERO, U256::from(LIMIT)) + .call() + .await?; + + 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, "expected {LIMIT} keys, got {}", vec.len()); + + Ok(()) + } + #[tokio::test] /// Tests that a successful SSV network fetch is handled and parsed properly async fn test_ssv_network_fetch() -> eyre::Result<()> { diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index 077b4ccd..e5eb593c 100644 --- a/crates/common/src/types.rs +++ b/crates/common/src/types.rs @@ -29,7 +29,7 @@ pub struct JwtClaims { pub module: String, } -#[derive(Clone, Copy, PartialEq, Eq)] +#[derive(Clone, Copy, PartialEq, Eq, Hash)] pub enum Chain { Mainnet, Holesky, @@ -44,6 +44,26 @@ pub enum Chain { }, } +pub enum MainnetLidoModule { + Curated = 1, + SimpleDVT = 2, + CommunityStaking = 3 +} + +pub enum HoleskyLidoModule { + Curated = 1, + SimpleDVT = 2, + Sandbox = 3, + CommunityStaking = 4 +} + +pub enum HoodiLidoModule { + Curated = 1, + SimpleDVT = 2, + Sandbox = 3, + CommunityStaking = 4 +} + pub type ForkVersion = [u8; 4]; impl std::fmt::Display for Chain { diff --git a/examples/configs/pbs_mux.toml b/examples/configs/pbs_mux.toml index 3ea9f355..fcf4ea8c 100644 --- a/examples/configs/pbs_mux.toml +++ b/examples/configs/pbs_mux.toml @@ -33,7 +33,7 @@ target_first_request_ms = 200 [[mux]] id = "lido-mux" -loader = { registry = "lido", node_operator_id = 8 } +loader = { registry = "lido", node_operator_id = 8, lido_module_id = 1 } [[mux.relays]] id = "relay-3"