diff --git a/crates/common/src/abi/StaderRegistry.json b/crates/common/src/abi/StaderRegistry.json new file mode 100644 index 00000000..8ed5125e --- /dev/null +++ b/crates/common/src/abi/StaderRegistry.json @@ -0,0 +1,244 @@ +[ + { + "constant": true, + "inputs": [ + { + "name": "_operatorId", + "type": "uint256" + } + ], + "name": "operatorStructById", + "outputs": [ + { + "name": "active", + "type": "bool" + }, + { + "name": "optedForSocializingPool", + "type": "bool" + }, + { + "name": "operatorName", + "type": "string" + }, + { + "name": "operatorRewardAddress", + "type": "address" + }, + { + "name": "operatorAddress", + "type": "address" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_operator", + "type": "address" + } + ], + "name": "operatorIDByAddress", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_operatorId", + "type": "uint256" + } + ], + "name": "getOperatorTotalKeys", + "outputs": [ + { + "name": "_totalKeys", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_operator", + "type": "address" + }, + { + "name": "_pageNumber", + "type": "uint256" + }, + { + "name": "_pageSize", + "type": "uint256" + } + ], + "name": "getValidatorsByOperator", + "outputs": [ + { + "name": "", + "type": "tuple[]", + "components": [ + { + "name": "status", + "type": "uint8" + }, + { + "name": "pubkey", + "type": "bytes" + }, + { + "name": "preDepositSignature", + "type": "bytes" + }, + { + "name": "depositSignature", + "type": "bytes" + }, + { + "name": "withdrawVaultAddress", + "type": "address" + }, + { + "name": "operatorId", + "type": "uint256" + }, + { + "name": "depositBlock", + "type": "uint256" + }, + { + "name": "withdrawnBlock", + "type": "uint256" + } + ] + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_operatorId", + "type": "uint256" + }, + { + "name": "_index", + "type": "uint256" + } + ], + "name": "validatorIdsByOperatorId", + "outputs": [ + { + "name": "", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_validatorId", + "type": "uint256" + } + ], + "name": "validatorRegistry", + "outputs": [ + { + "name": "status", + "type": "uint8" + }, + { + "name": "pubkey", + "type": "bytes" + }, + { + "name": "preDepositSignature", + "type": "bytes" + }, + { + "name": "depositSignature", + "type": "bytes" + }, + { + "name": "withdrawVaultAddress", + "type": "address" + }, + { + "name": "operatorId", + "type": "uint256" + }, + { + "name": "depositBlock", + "type": "uint256" + }, + { + "name": "withdrawnBlock", + "type": "uint256" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_operatorId", + "type": "uint256" + } + ], + "name": "getOperatorRewardAddress", + "outputs": [ + { + "name": "", + "type": "address" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { + "name": "_operAddr", + "type": "address" + } + ], + "name": "isExistingOperator", + "outputs": [ + { + "name": "", + "type": "bool" + } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + } +] \ No newline at end of file diff --git a/crates/common/src/config/mux.rs b/crates/common/src/config/mux.rs index b5436ae2..55924b0b 100644 --- a/crates/common/src/config/mux.rs +++ b/crates/common/src/config/mux.rs @@ -19,9 +19,10 @@ use tracing::{debug, info, warn}; use url::Url; use super::{MUX_PATH_ENV, PbsConfig, RelayConfig, load_optional_env_var}; +use crate::types::StaderPool; use crate::{ config::{remove_duplicate_keys, safe_read_http_response}, - interop::{lido::utils::*, ssv::utils::*}, + interop::{lido::utils::*, ssv::utils::*, stader::utils::*}, pbs::RelayClient, types::{BlsPublicKey, Chain}, utils::default_bool, @@ -194,6 +195,8 @@ pub enum MuxKeysLoader { node_operator_id: u64, #[serde(default)] lido_module_id: Option, + #[serde(default)] + stader_pool: Option, #[serde(default = "default_bool::")] enable_refreshing: bool, }, @@ -205,6 +208,8 @@ pub enum NORegistry { Lido, #[serde(alias = "ssv")] SSV, + #[serde(alias = "stader")] + Stader, } impl MuxKeysLoader { @@ -240,33 +245,56 @@ impl MuxKeysLoader { .wrap_err("failed to fetch mux keys from HTTP endpoint") } - 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 - } + Self::Registry { + registry, + node_operator_id, + lido_module_id, + stader_pool, + 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 + } + NORegistry::Stader => { + let Some(rpc_url) = rpc_url else { + bail!("Stader registry requires RPC URL to be set in the PBS config"); + }; + + let Some(stader_pool) = stader_pool else { + bail!("Stader registry requires `stader_pool` to be set in the mux config"); + }; + + fetch_stader_registry_keys( + rpc_url, + chain, + stader_pool.clone(), + U256::from(*node_operator_id), + http_timeout, + ) + .await + } + }, }?; // Remove duplicates @@ -390,6 +418,33 @@ async fn fetch_lido_registry_keys( } } +async fn fetch_stader_registry_keys( + rpc_url: Url, + chain: Chain, + stader_pool: StaderPool, + node_operator_id: U256, + http_timeout: Duration, +) -> eyre::Result> { + 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 = stader_registry_address(chain, stader_pool)?; + + let provider = ProviderBuilder::new().connect_client(rpc_client); + let registry = get_stader_registry(registry_address, provider); + + let operator_address = fetch_stader_operator_address(®istry, node_operator_id).await?; + + let total_keys = fetch_stader_keys_total(®istry, node_operator_id).await?; + + collect_registry_keys(total_keys, |offset, limit| { + fetch_stader_keys_batch(®istry, operator_address, offset, limit) + }) + .await +} + async fn fetch_ssv_pubkeys( mut api_url: Url, chain: Chain, diff --git a/crates/common/src/interop/mod.rs b/crates/common/src/interop/mod.rs index 4d0230a9..0e2b1035 100644 --- a/crates/common/src/interop/mod.rs +++ b/crates/common/src/interop/mod.rs @@ -1,2 +1,3 @@ pub mod lido; pub mod ssv; +pub mod stader; diff --git a/crates/common/src/interop/stader/mod.rs b/crates/common/src/interop/stader/mod.rs new file mode 100644 index 00000000..b4ab6a6a --- /dev/null +++ b/crates/common/src/interop/stader/mod.rs @@ -0,0 +1,2 @@ +pub mod types; +pub mod utils; diff --git a/crates/common/src/interop/stader/types.rs b/crates/common/src/interop/stader/types.rs new file mode 100644 index 00000000..8fe0a2dd --- /dev/null +++ b/crates/common/src/interop/stader/types.rs @@ -0,0 +1,8 @@ +use alloy::sol; + +sol! { + #[allow(missing_docs)] + #[sol(rpc)] + StaderRegistry, + "src/abi/StaderRegistry.json" +} diff --git a/crates/common/src/interop/stader/utils.rs b/crates/common/src/interop/stader/utils.rs new file mode 100644 index 00000000..562ffd60 --- /dev/null +++ b/crates/common/src/interop/stader/utils.rs @@ -0,0 +1,380 @@ +use std::collections::HashMap; + +use crate::interop::stader::types::StaderRegistry; +use crate::types::{Chain, StaderPool}; +use alloy::primitives::{Address, Bytes, U256, address}; +use alloy::rpc::types::beacon::constants::BLS_PUBLIC_KEY_BYTES_LEN; +use eyre::ensure; +use lazy_static::lazy_static; + +const REGISTRY_CALL_BATCH_SIZE: u64 = 250u64; + +lazy_static! { + static ref STADER_REGISTRY_ADDRESSES_BY_MODULE: HashMap> = { + let mut map: HashMap> = HashMap::new(); + + // --- Mainnet --- + let mut mainnet = HashMap::new(); + mainnet.insert( + StaderPool::Permissioned, + address!("af42d795a6d279e9dcc19dc0ee1ce3ecd4ecf5dd"), + ); + mainnet.insert( + StaderPool::Permissionless, + address!("4f4bfa0861f62309934a5551e0b2541ee82fdcf1"), + ); + map.insert(Chain::Mainnet, mainnet); + map + }; +} + +pub fn stader_registry_address(chain: Chain, stader_pool: StaderPool) -> eyre::Result
{ + crate::interop::stader::utils::STADER_REGISTRY_ADDRESSES_BY_MODULE + .get(&chain) + .ok_or_else(|| eyre::eyre!("Stader registry not supported for chain: {chain:?}"))? + .get(&stader_pool) + .copied() + .ok_or_else(|| eyre::eyre!("Stader pool {:?} not found for chain: {chain:?}", stader_pool)) +} + +pub fn get_stader_registry

( + registry_address: Address, + provider: P, +) -> StaderRegistry::StaderRegistryInstance

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

( + registry: &StaderRegistry::StaderRegistryInstance

, + node_operator_id: U256, +) -> eyre::Result

+where + P: Clone + Send + Sync + 'static + alloy::providers::Provider, +{ + let operator = registry.operatorStructById(node_operator_id).call().await?; + + ensure!( + operator.operatorAddress != Address::ZERO, + "Stader operator {node_operator_id} has zero operator address" + ); + + Ok(operator.operatorAddress) +} + +pub async fn fetch_stader_keys_total

( + registry: &StaderRegistry::StaderRegistryInstance

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

( + registry: &StaderRegistry::StaderRegistryInstance

, + operator_address: Address, + offset: u64, + limit: u64, +) -> eyre::Result +where + P: Clone + Send + Sync + 'static + alloy::providers::Provider, +{ + let page_number = (offset / REGISTRY_CALL_BATCH_SIZE) + 1; + + let validators = registry + .getValidatorsByOperator( + operator_address, + U256::from(page_number), + U256::from(REGISTRY_CALL_BATCH_SIZE), + ) + .call() + .await?; + + ensure!( + validators.len() <= limit as usize, + "Stader returned more validators than expected in batch, expected at most {limit}, got {}", + validators.len() + ); + + let mut pubkeys = Vec::with_capacity(validators.len() * BLS_PUBLIC_KEY_BYTES_LEN); + + for validator in validators { + let pubkey = validator.1; + + ensure!( + pubkey.len() == BLS_PUBLIC_KEY_BYTES_LEN, + "unexpected Stader validator pubkey length, expected {}, got {}", + BLS_PUBLIC_KEY_BYTES_LEN, + pubkey.len() + ); + + pubkeys.extend_from_slice(pubkey.as_ref()); + } + + Ok(Bytes::from(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::types::BlsPublicKey; + + const MAINNET_RPC_URL: &str = "https://ethereum-rpc.publicnode.com"; + const MAX_OPERATOR_ID_TO_SCAN: u64 = 200; + + fn deserialize_pubkeys(pubkeys: &Bytes) -> eyre::Result> { + ensure!( + pubkeys.len() % BLS_PUBLIC_KEY_BYTES_LEN == 0, + "unexpected pubkeys bytes length, expected multiple of {}, got {}", + BLS_PUBLIC_KEY_BYTES_LEN, + pubkeys.len() + ); + + let mut keys = Vec::new(); + + for chunk in pubkeys.chunks(BLS_PUBLIC_KEY_BYTES_LEN) { + keys.push( + BlsPublicKey::deserialize(chunk) + .map_err(|_| eyre::eyre!("invalid BLS public key"))?, + ); + } + + Ok(keys) + } + + fn mainnet_registry( + pool: StaderPool, + ) -> eyre::Result< + StaderRegistry::StaderRegistryInstance< + impl alloy::providers::Provider + Clone + Send + Sync + 'static, + >, + > { + let url = Url::parse(MAINNET_RPC_URL)?; + let provider = ProviderBuilder::new().connect_http(url); + + let registry_address = stader_registry_address(Chain::Mainnet, pool)?; + + Ok(StaderRegistry::new(registry_address, provider)) + } + + async fn find_operator_with_min_keys

( + registry: &StaderRegistry::StaderRegistryInstance

, + min_keys: u64, + ) -> eyre::Result<(U256, Address, u64)> + where + P: Clone + Send + Sync + 'static + alloy::providers::Provider, + { + for operator_id in 1..=MAX_OPERATOR_ID_TO_SCAN { + let operator_id = U256::from(operator_id); + + let total_keys = match fetch_stader_keys_total(registry, operator_id).await { + Ok(total_keys) => total_keys, + Err(_) => continue, + }; + + if total_keys < min_keys { + continue; + } + + let operator_address = match fetch_stader_operator_address(registry, operator_id).await + { + Ok(operator_address) => operator_address, + Err(_) => continue, + }; + + return Ok((operator_id, operator_address, total_keys)); + } + + eyre::bail!( + "could not find Stader operator with at least {min_keys} keys in first {MAX_OPERATOR_ID_TO_SCAN} operator ids" + ); + } + + #[tokio::test] + async fn test_stader_registry_address() -> eyre::Result<()> { + assert_eq!( + stader_registry_address(Chain::Mainnet, StaderPool::Permissioned)?, + address!("af42d795a6d279e9dcc19dc0ee1ce3ecd4ecf5dd") + ); + + assert_eq!( + stader_registry_address(Chain::Mainnet, StaderPool::Permissionless)?, + address!("4f4bfa0861f62309934a5551e0b2541ee82fdcf1") + ); + + assert!(stader_registry_address(Chain::Holesky, StaderPool::Permissioned).is_err()); + assert!(stader_registry_address(Chain::Holesky, StaderPool::Permissionless).is_err()); + + Ok(()) + } + + #[tokio::test] + async fn test_stader_permissioned_registry_first_batch() -> eyre::Result<()> { + let registry = mainnet_registry(StaderPool::Permissioned)?; + + const LIMIT: u64 = 3; + + let (operator_id, operator_address, total_keys) = + find_operator_with_min_keys(®istry, LIMIT).await?; + + let resolved_operator_address = + fetch_stader_operator_address(®istry, operator_id).await?; + + assert_eq!(resolved_operator_address, operator_address); + + let fetched_total_keys = fetch_stader_keys_total(®istry, operator_id).await?; + + assert_eq!(fetched_total_keys, total_keys); + assert!(total_keys >= LIMIT); + + let limit = REGISTRY_CALL_BATCH_SIZE.min(total_keys); + + let pubkeys = fetch_stader_keys_batch(®istry, operator_address, 0, limit).await?; + + let keys = deserialize_pubkeys(&pubkeys)?; + + assert_eq!(keys.len(), limit as usize); + + Ok(()) + } + + #[tokio::test] + async fn test_stader_permissionless_registry_first_batch() -> eyre::Result<()> { + let registry = mainnet_registry(StaderPool::Permissionless)?; + + const LIMIT: u64 = 3; + + let (operator_id, operator_address, total_keys) = + find_operator_with_min_keys(®istry, LIMIT).await?; + + let resolved_operator_address = + fetch_stader_operator_address(®istry, operator_id).await?; + + assert_eq!(resolved_operator_address, operator_address); + + let fetched_total_keys = fetch_stader_keys_total(®istry, operator_id).await?; + + assert_eq!(fetched_total_keys, total_keys); + assert!(total_keys >= LIMIT); + + let limit = REGISTRY_CALL_BATCH_SIZE.min(total_keys); + + let pubkeys = fetch_stader_keys_batch(®istry, operator_address, 0, limit).await?; + + let keys = deserialize_pubkeys(&pubkeys)?; + + assert_eq!(keys.len(), limit as usize); + + Ok(()) + } + + #[tokio::test] + async fn test_stader_permissioned_batch_pagination_matches_contract_page_two() + -> eyre::Result<()> { + let registry = mainnet_registry(StaderPool::Permissioned)?; + + let min_keys = REGISTRY_CALL_BATCH_SIZE + 1; + + let (_operator_id, operator_address, total_keys) = + find_operator_with_min_keys(®istry, min_keys).await?; + + assert!( + total_keys > REGISTRY_CALL_BATCH_SIZE, + "expected operator with more than {REGISTRY_CALL_BATCH_SIZE} keys, got {total_keys}" + ); + + let offset = REGISTRY_CALL_BATCH_SIZE; + let limit = REGISTRY_CALL_BATCH_SIZE.min(total_keys - offset); + + let pubkeys = fetch_stader_keys_batch(®istry, operator_address, offset, limit).await?; + + let keys = deserialize_pubkeys(&pubkeys)?; + + assert_eq!(keys.len(), limit as usize); + + let direct_validators = registry + .getValidatorsByOperator( + operator_address, + U256::from(2), + U256::from(REGISTRY_CALL_BATCH_SIZE), + ) + .call() + .await?; + + assert_eq!( + direct_validators.len(), + limit as usize, + "batch helper and direct Stader page 2 returned different lengths" + ); + + let pubkeys_bytes = pubkeys.as_ref(); + + for (index, validator) in direct_validators.iter().enumerate() { + let expected_pubkey = validator.1.as_ref(); + + let start = index * BLS_PUBLIC_KEY_BYTES_LEN; + let end = start + BLS_PUBLIC_KEY_BYTES_LEN; + + assert_eq!( + &pubkeys_bytes[start..end], + expected_pubkey, + "pubkey mismatch at page 2 index {index}" + ); + } + + Ok(()) + } + + #[tokio::test] + async fn test_stader_permissioned_first_and_second_batches_do_not_overlap() -> eyre::Result<()> + { + let registry = mainnet_registry(StaderPool::Permissioned)?; + + let min_keys = REGISTRY_CALL_BATCH_SIZE + 1; + + let (_operator_id, operator_address, total_keys) = + find_operator_with_min_keys(®istry, min_keys).await?; + + let first_batch_limit = REGISTRY_CALL_BATCH_SIZE.min(total_keys); + + let first_batch = + fetch_stader_keys_batch(®istry, operator_address, 0, first_batch_limit).await?; + + let second_batch_limit = + REGISTRY_CALL_BATCH_SIZE.min(total_keys - REGISTRY_CALL_BATCH_SIZE); + + let second_batch = fetch_stader_keys_batch( + ®istry, + operator_address, + REGISTRY_CALL_BATCH_SIZE, + second_batch_limit, + ) + .await?; + + let first_keys = deserialize_pubkeys(&first_batch)?; + let second_keys = deserialize_pubkeys(&second_batch)?; + + assert!(!first_keys.is_empty()); + assert!(!second_keys.is_empty()); + + assert_ne!( + first_keys[0], second_keys[0], + "first key of page 1 and page 2 should not be the same" + ); + + Ok(()) + } +} diff --git a/crates/common/src/types.rs b/crates/common/src/types.rs index 6b06d040..18921d79 100644 --- a/crates/common/src/types.rs +++ b/crates/common/src/types.rs @@ -64,6 +64,15 @@ pub enum HoodiLidoModule { CommunityStaking = 4, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Hash)] +pub enum StaderPool { + #[serde(alias = "permissioned")] + Permissioned, + + #[serde(alias = "permissionless")] + Permissionless, +} + 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 fcf4ea8c..b26e0623 100644 --- a/examples/configs/pbs_mux.toml +++ b/examples/configs/pbs_mux.toml @@ -46,3 +46,12 @@ loader = { registry = "ssv", node_operator_id = 200 } [[mux.relays]] id = "relay-4" url = "http://0x80c7f782b2467c5898c5516a8b6595d75623960b4afc4f71ee07d40985d20e117ba35e7cd352a3e75fb85a8668a3b745@fgh.xyz" + +[[mux]] +id = "stader-mux" +loader = { registry = "stader", node_operator_id = 200, stader_pool = 'permissioned' } + +[[mux.relays]] +id = "relay-5" +url = "http://0x80c7f782b2467c5898c5516a8b6595d75623960b4afc4f71ee07d40985d20e117ba35e7cd352a3e75fb85a8668a3b745@fgh.xyz" + diff --git a/tests/tests/pbs_mux_refresh.rs b/tests/tests/pbs_mux_refresh.rs index da582ec7..12bb8905 100644 --- a/tests/tests/pbs_mux_refresh.rs +++ b/tests/tests/pbs_mux_refresh.rs @@ -74,6 +74,7 @@ async fn test_auto_refresh() -> Result<()> { node_operator_id: 1, lido_module_id: None, registry: cb_common::config::NORegistry::SSV, + stader_pool: None }; let muxes = PbsMuxes { muxes: vec![MuxConfig {