diff --git a/Cargo.lock b/Cargo.lock index b7d77991..83f908bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1625,6 +1625,7 @@ dependencies = [ "eyre", "futures", "jsonwebtoken", + "lazy_static", "pbkdf2 0.12.2", "rand 0.9.2", "rayon", diff --git a/config.example.toml b/config.example.toml index 2bcd0efe..6ea6a1b6 100644 --- a/config.example.toml +++ b/config.example.toml @@ -150,7 +150,7 @@ validator_pubkeys = [ # OPTIONAL loader = "./tests/data/mux_keys.example.json" # loader = { url = "http://localhost:8000/keys" } -# loader = { registry = "lido", node_operator_id = 8, enable_refreshing = false } +# loader = { registry = "lido", node_operator_id = 8, lido_module_id = 1, enable_refreshing = false } # loader = { registry = "ssv", node_operator_id = 8, enable_refreshing = false } late_in_slot_time_ms = 1500 timeout_get_header_ms = 900 diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 57a5fbb3..35367a12 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -26,6 +26,7 @@ ethereum_ssz_derive.workspace = true eyre.workspace = true futures.workspace = true jsonwebtoken.workspace = true +lazy_static.workspace = true lh_eth2.workspace = true lh_eth2_keystore.workspace = true lh_types.workspace = true 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 419a097b..27950d1c 100644 --- a/crates/common/src/config/mux.rs +++ b/crates/common/src/config/mux.rs @@ -7,10 +7,9 @@ use std::{ }; use alloy::{ - primitives::{Address, U256, address}, + primitives::{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}; @@ -22,7 +21,7 @@ use url::Url; use super::{MUX_PATH_ENV, PbsConfig, RelayConfig, load_optional_env_var}; use crate::{ config::{remove_duplicate_keys, safe_read_http_response}, - interop::ssv::utils::fetch_ssv_pubkeys_from_url, + interop::{lido::utils::*, ssv::utils::*}, pbs::RelayClient, types::{BlsPublicKey, Chain}, utils::default_bool, @@ -193,6 +192,8 @@ pub enum MuxKeysLoader { Registry { registry: NORegistry, node_operator_id: u64, + #[serde(default)] + lido_module_id: Option, #[serde(default = "default_bool::")] enable_refreshing: bool, }, @@ -239,30 +240,33 @@ impl MuxKeysLoader { .wrap_err("failed to fetch mux keys from HTTP endpoint") } - Self::Registry { registry, node_operator_id, enable_refreshing: _ } => match registry { - NORegistry::Lido => { - let Some(rpc_url) = rpc_url else { - bail!("Lido registry requires RPC URL to be set in the PBS config"); - }; - - fetch_lido_registry_keys( - rpc_url, - chain, - U256::from(*node_operator_id), - http_timeout, - ) - .await - } - NORegistry::SSV => { - fetch_ssv_pubkeys( - ssv_api_url, - chain, - U256::from(*node_operator_id), - http_timeout, - ) - .await + Self::Registry { registry, node_operator_id, lido_module_id, enable_refreshing: _ } => { + match registry { + NORegistry::Lido => { + let Some(rpc_url) = rpc_url else { + bail!("Lido registry requires RPC URL to be set in the PBS config"); + }; + + fetch_lido_registry_keys( + rpc_url, + chain, + U256::from(*node_operator_id), + lido_module_id.unwrap_or(1), + http_timeout, + ) + .await + } + NORegistry::SSV => { + fetch_ssv_pubkeys( + ssv_api_url, + chain, + U256::from(*node_operator_id), + http_timeout, + ) + .await + } } - }, + } }?; // Remove duplicates @@ -285,63 +289,28 @@ fn get_mux_path(mux_id: &str) -> String { format!("/{mux_id}-mux_keys.json") } -sol! { - #[allow(missing_docs)] - #[sol(rpc)] - LidoRegistry, - "src/abi/LidoNORegistry.json" -} - -// Fetching Lido Curated Module -fn lido_registry_address(chain: Chain) -> eyre::Result
{ - 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:?}"), - } -} - -async fn fetch_lido_registry_keys( - rpc_url: Url, - chain: Chain, - node_operator_id: U256, - http_timeout: Duration, -) -> eyre::Result> { - debug!(?chain, %node_operator_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 provider = ProviderBuilder::new().connect_client(rpc_client); - - let registry_address = lido_registry_address(chain)?; - let registry = LidoRegistry::new(registry_address, provider); - - let total_keys = registry.getTotalSigningKeyCount(node_operator_id).call().await?.try_into()?; - +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, @@ -368,6 +337,59 @@ 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?; + + 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?; + + 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( mut api_url: Url, chain: Chain, @@ -421,46 +443,3 @@ async fn fetch_ssv_pubkeys( Ok(pubkeys) } - -#[cfg(test)] -mod tests { - use alloy::{primitives::U256, providers::ProviderBuilder}; - use url::Url; - - use super::*; - - #[tokio::test] - async fn test_lido_registry_address() -> eyre::Result<()> { - let url = Url::parse("https://ethereum-rpc.publicnode.com")?; - let provider = ProviderBuilder::new().connect_http(url); - - let registry = - LidoRegistry::new(address!("55032650b14df07b85bF18A3a3eC8E0Af2e028d5"), provider); - - const LIMIT: usize = 3; - let node_operator_id = U256::from(1); - - let total_keys: u64 = - registry.getTotalSigningKeyCount(node_operator_id).call().await?.try_into()?; - - assert!(total_keys > LIMIT as u64); - - let pubkeys = registry - .getSigningKeys(node_operator_id, U256::ZERO, U256::from(LIMIT)) - .call() - .await? - .pubkeys; - - let mut vec = vec![]; - 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); - - Ok(()) - } -} diff --git a/crates/common/src/interop/lido/mod.rs b/crates/common/src/interop/lido/mod.rs new file mode 100644 index 00000000..b4ab6a6a --- /dev/null +++ b/crates/common/src/interop/lido/mod.rs @@ -0,0 +1,2 @@ +pub mod types; +pub mod utils; diff --git a/crates/common/src/interop/lido/types.rs b/crates/common/src/interop/lido/types.rs new file mode 100644 index 00000000..48aad122 --- /dev/null +++ b/crates/common/src/interop/lido/types.rs @@ -0,0 +1,15 @@ +use alloy::sol; + +sol! { + #[allow(missing_docs)] + #[sol(rpc)] + LidoRegistry, + "src/abi/LidoNORegistry.json" +} + +sol! { + #[allow(missing_docs)] + #[sol(rpc)] + LidoCSMRegistry, + "src/abi/LidoCSModuleNORegistry.json" +} diff --git a/crates/common/src/interop/lido/utils.rs b/crates/common/src/interop/lido/utils.rs new file mode 100644 index 00000000..02ff7c42 --- /dev/null +++ b/crates/common/src/interop/lido/utils.rs @@ -0,0 +1,271 @@ +use std::collections::HashMap; + +use alloy::primitives::{Address, Bytes, U256, address}; +use eyre::Context; +use lazy_static::lazy_static; + +use crate::{ + interop::lido::types::{ + LidoCSMRegistry::{self, getNodeOperatorSummaryReturn}, + LidoRegistry, + }, + types::{Chain, HoleskyLidoModule, HoodiLidoModule, MainnetLidoModule}, +}; + +lazy_static! { + static ref 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 +pub 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) + }) +} + +pub fn is_csm_module(chain: Chain, module_id: u8) -> bool { + match 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, + } +} + +pub 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) +} + +pub 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) +} + +pub async fn fetch_lido_csm_keys_total

( + registry: &LidoCSMRegistry::LidoCSMRegistryInstance

, + node_operator_id: U256, +) -> eyre::Result +where + P: Clone + Send + Sync + 'static + alloy::providers::Provider, +{ + let summary: getNodeOperatorSummaryReturn = + registry.getNodeOperatorSummary(node_operator_id).call().await?; + + 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) +} + +pub 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) +} + +pub 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) +} + +pub 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; + + Ok(pubkeys) +} + +#[cfg(test)] +mod tests { + use alloy::{ + primitives::{U256, address}, + providers::ProviderBuilder, + rpc::types::beacon::constants::BLS_PUBLIC_KEY_BYTES_LEN, + }; + use url::Url; + + use super::*; + use crate::{interop::lido::types::LidoRegistry, types::BlsPublicKey}; + + #[tokio::test] + async fn test_lido_registry_address() -> eyre::Result<()> { + let url = Url::parse("https://ethereum-rpc.publicnode.com")?; + let provider = ProviderBuilder::new().connect_http(url); + + let registry = + LidoRegistry::new(address!("55032650b14df07b85bF18A3a3eC8E0Af2e028d5"), provider); + + const LIMIT: usize = 3; + let node_operator_id = U256::from(1); + + let total_keys: u64 = + registry.getTotalSigningKeyCount(node_operator_id).call().await?.try_into()?; + + assert!(total_keys > LIMIT as u64); + + let pubkeys = registry + .getSigningKeys(node_operator_id, U256::ZERO, U256::from(LIMIT)) + .call() + .await? + .pubkeys; + + let mut vec = vec![]; + 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); + + Ok(()) + } + + #[tokio::test] + async fn test_lido_csm_registry_address() -> eyre::Result<()> { + 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(()) + } +} diff --git a/crates/common/src/interop/mod.rs b/crates/common/src/interop/mod.rs index 42502f6f..4d0230a9 100644 --- a/crates/common/src/interop/mod.rs +++ b/crates/common/src/interop/mod.rs @@ -1 +1,2 @@ +pub mod lido; pub mod ssv; diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index 6d6d55f1..89934471 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" diff --git a/tests/tests/pbs_mux_refresh.rs b/tests/tests/pbs_mux_refresh.rs index 44979fbe..da582ec7 100644 --- a/tests/tests/pbs_mux_refresh.rs +++ b/tests/tests/pbs_mux_refresh.rs @@ -72,6 +72,7 @@ async fn test_auto_refresh() -> Result<()> { let loader = MuxKeysLoader::Registry { enable_refreshing: true, node_operator_id: 1, + lido_module_id: None, registry: cb_common::config::NORegistry::SSV, }; let muxes = PbsMuxes {