Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,13 @@ extra_validation_enabled = false
# Execution Layer RPC url to use for extra validation
# OPTIONAL
# rpc_url = "https://ethereum-holesky-rpc.publicnode.com"
# URL of the SSV API server to use, if you have a mux that targets an SSV node operator
# OPTIONAL, DEFAULT: "https://api.ssv.network/api/v4"
# ssv_api_url = "https://api.ssv.network/api/v4"
# URL of your local SSV node API endpoint, if you have a mux that targets an SSV node operator
# OPTIONAL, DEFAULT: "http://localhost:16000/v1/"
# ssv_node_api_url = "http://localhost:16000/v1/"
# URL of the public SSV API server, if you have a mux that targets an SSV node operator. This is used as
# a fallback if the user's own SSV node is not reachable.
# OPTIONAL, DEFAULT: "https://api.ssv.network/api/v4/"
# ssv_public_api_url = "https://api.ssv.network/api/v4/"
# Timeout for any HTTP requests sent from the PBS module to other services, in seconds
# OPTIONAL, DEFAULT: 10
http_timeout_seconds = 10
Expand Down
75 changes: 60 additions & 15 deletions crates/common/src/config/mux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ impl PbsMuxes {
.load(
&mux.id,
chain,
default_pbs.ssv_api_url.clone(),
default_pbs.ssv_node_api_url.clone(),
default_pbs.ssv_public_api_url.clone(),
default_pbs.rpc_url.clone(),
http_timeout,
)
Expand Down Expand Up @@ -212,7 +213,8 @@ impl MuxKeysLoader {
&self,
mux_id: &str,
chain: Chain,
ssv_api_url: Url,
ssv_node_api_url: Url,
ssv_public_api_url: Url,
rpc_url: Option<Url>,
http_timeout: Duration,
) -> eyre::Result<Vec<BlsPublicKey>> {
Expand Down Expand Up @@ -258,7 +260,8 @@ impl MuxKeysLoader {
}
NORegistry::SSV => {
fetch_ssv_pubkeys(
ssv_api_url,
ssv_node_api_url,
ssv_public_api_url,
chain,
U256::from(*node_operator_id),
http_timeout,
Expand Down Expand Up @@ -391,11 +394,62 @@ async fn fetch_lido_registry_keys(
}

async fn fetch_ssv_pubkeys(
mut api_url: Url,
node_url: Url,
public_url: Url,
chain: Chain,
node_operator_id: U256,
http_timeout: Duration,
) -> eyre::Result<Vec<BlsPublicKey>> {
// Try the node API first
match fetch_ssv_pubkeys_from_ssv_node(node_url.clone(), node_operator_id, http_timeout).await {
Ok(pubkeys) => Ok(pubkeys),
Err(e) => {
// Fall back to public API
warn!(
"failed to fetch pubkeys from SSV node API at {node_url}: {e}; falling back to public API",
);
fetch_ssv_pubkeys_from_public_api(public_url, chain, node_operator_id, http_timeout)
.await
}
}
}

/// Ensures that the SSV API URL has a trailing slash
fn ensure_ssv_api_url(url: &mut Url) -> eyre::Result<()> {
// Validate the URL - this appends a trailing slash if missing as efficiently as
// possible
if !url.path().ends_with('/') {
match url.path_segments_mut() {
Ok(mut segments) => segments.push(""), // Analogous to a trailing slash
Err(_) => bail!("SSV API URL is not a valid base URL"),
};
}
Ok(())
}

/// Fetches SSV pubkeys from the user's SSV node
async fn fetch_ssv_pubkeys_from_ssv_node(
mut url: Url,
node_operator_id: U256,
http_timeout: Duration,
) -> eyre::Result<Vec<BlsPublicKey>> {
ensure_ssv_api_url(&mut url)?;
let route = "validators";
let url = url.join(route).wrap_err("failed to construct SSV API URL")?;

let response = request_ssv_pubkeys_from_ssv_node(url, node_operator_id, http_timeout).await?;
let pubkeys = response.data.into_iter().map(|v| v.public_key).collect::<Vec<BlsPublicKey>>();
Ok(pubkeys)
}

/// Fetches SSV pubkeys from the public SSV network API with pagination
async fn fetch_ssv_pubkeys_from_public_api(
mut url: Url,
chain: Chain,
node_operator_id: U256,
http_timeout: Duration,
) -> eyre::Result<Vec<BlsPublicKey>> {
ensure_ssv_api_url(&mut url)?;
const MAX_PER_PAGE: usize = 100;

let chain_name = match chain {
Expand All @@ -408,22 +462,13 @@ async fn fetch_ssv_pubkeys(
let mut pubkeys: Vec<BlsPublicKey> = vec![];
let mut page = 1;

// Validate the URL - this appends a trailing slash if missing as efficiently as
// possible
if !api_url.path().ends_with('/') {
match api_url.path_segments_mut() {
Ok(mut segments) => segments.push(""), // Analogous to a trailing slash
Err(_) => bail!("SSV API URL is not a valid base URL"),
};
}

loop {
let route = format!(
"{chain_name}/validators/in_operator/{node_operator_id}?perPage={MAX_PER_PAGE}&page={page}",
);
let url = api_url.join(&route).wrap_err("failed to construct SSV API URL")?;
let url = url.join(&route).wrap_err("failed to construct SSV API URL")?;

let response = fetch_ssv_pubkeys_from_url(url, http_timeout).await?;
let response = request_ssv_pubkeys_from_public_api(url, http_timeout).await?;
let fetched = response.validators.len();
pubkeys.extend(
response.validators.into_iter().map(|v| v.pubkey).collect::<Vec<BlsPublicKey>>(),
Expand Down
18 changes: 13 additions & 5 deletions crates/common/src/config/pbs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,12 @@ pub struct PbsConfig {
pub extra_validation_enabled: bool,
/// Execution Layer RPC url to use for extra validation
pub rpc_url: Option<Url>,
/// URL for the SSV network API
#[serde(default = "default_ssv_api_url")]
pub ssv_api_url: Url,
/// URL for the user's own SSV node API endpoint
#[serde(default = "default_ssv_node_api_url")]
pub ssv_node_api_url: Url,
/// URL for the public SSV network API server
#[serde(default = "default_public_ssv_api_url")]
pub ssv_public_api_url: Url,
/// Timeout for HTTP requests in seconds
#[serde(default = "default_u64::<HTTP_TIMEOUT_SECONDS_DEFAULT>")]
pub http_timeout_seconds: u64,
Expand Down Expand Up @@ -402,7 +405,12 @@ pub async fn load_pbs_custom_config<T: DeserializeOwned>() -> Result<(PbsModuleC
))
}

/// Default URL for the SSV network API
fn default_ssv_api_url() -> Url {
/// Default URL for the user's SSV node API endpoint (/v1/validators).
fn default_ssv_node_api_url() -> Url {
Url::parse("http://localhost:16000/v1/").expect("default URL is valid")
}

/// Default URL for the public SSV network API.
fn default_public_ssv_api_url() -> Url {
Url::parse("https://api.ssv.network/api/v4/").expect("default URL is valid")
}
61 changes: 55 additions & 6 deletions crates/common/src/interop/ssv/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,73 @@ use serde::{Deserialize, Deserializer, Serialize};

use crate::types::BlsPublicKey;

/// Response from the SSV API for validators
/// Response from the SSV API for validators (the new way, relies on using SSV
/// node API)
#[derive(Deserialize, Serialize)]
pub struct SSVResponse {
pub struct SSVNodeResponse {
/// List of validators returned by the SSV API
pub validators: Vec<SSVValidator>,
pub data: Vec<SSVNodeValidator>,
}

/// Representation of a validator in the SSV API
#[derive(Clone)]
pub struct SSVNodeValidator {
/// The public key of the validator
pub public_key: BlsPublicKey,
}

impl<'de> Deserialize<'de> for SSVNodeValidator {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
struct SSVValidator {
public_key: String,
}

let s = SSVValidator::deserialize(deserializer)?;
let bytes = alloy::hex::decode(&s.public_key).map_err(serde::de::Error::custom)?;
let pubkey = BlsPublicKey::deserialize(&bytes)
.map_err(|e| serde::de::Error::custom(format!("invalid BLS public key: {e:?}")))?;

Ok(Self { public_key: pubkey })
}
}

impl Serialize for SSVNodeValidator {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
#[derive(Serialize)]
struct SSVValidator {
public_key: String,
}

let s = SSVValidator { public_key: self.public_key.as_hex_string() };
s.serialize(serializer)
}
}

/// Response from the SSV API for validators from the public api.ssv.network URL
#[derive(Deserialize, Serialize)]
pub struct SSVPublicResponse {
/// List of validators returned by the SSV API
pub validators: Vec<SSVPublicValidator>,

/// Pagination information
pub pagination: SSVPagination,
}

/// Representation of a validator in the SSV API
#[derive(Clone)]
pub struct SSVValidator {
pub struct SSVPublicValidator {
/// The public key of the validator
pub pubkey: BlsPublicKey,
}

impl<'de> Deserialize<'de> for SSVValidator {
impl<'de> Deserialize<'de> for SSVPublicValidator {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
Expand All @@ -38,7 +87,7 @@ impl<'de> Deserialize<'de> for SSVValidator {
}
}

impl Serialize for SSVValidator {
impl Serialize for SSVPublicValidator {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
Expand Down
40 changes: 34 additions & 6 deletions crates/common/src/interop/ssv/utils.rs
Original file line number Diff line number Diff line change
@@ -1,24 +1,52 @@
use std::time::Duration;

use alloy::primitives::U256;
use eyre::Context;
use serde_json::json;
use url::Url;

use crate::{config::safe_read_http_response, interop::ssv::types::SSVResponse};
use crate::{
config::safe_read_http_response,
interop::ssv::types::{SSVNodeResponse, SSVPublicResponse},
};

pub async fn fetch_ssv_pubkeys_from_url(
pub async fn request_ssv_pubkeys_from_ssv_node(
url: Url,
node_operator_id: U256,
http_timeout: Duration,
) -> eyre::Result<SSVResponse> {
) -> eyre::Result<SSVNodeResponse> {
let client = reqwest::ClientBuilder::new().timeout(http_timeout).build()?;
let body = json!({
"operators": [node_operator_id]
});
let response = client.get(url).json(&body).send().await.map_err(|e| {
if e.is_timeout() {
eyre::eyre!("Request to SSV node timed out: {e}")
} else {
eyre::eyre!("Error sending request to SSV node: {e}")
}
})?;

// Parse the response as JSON
let body_bytes = safe_read_http_response(response).await?;
serde_json::from_slice::<SSVNodeResponse>(&body_bytes).wrap_err("failed to parse SSV response")
}

pub async fn request_ssv_pubkeys_from_public_api(
url: Url,
http_timeout: Duration,
) -> eyre::Result<SSVPublicResponse> {
let client = reqwest::ClientBuilder::new().timeout(http_timeout).build()?;
let response = client.get(url).send().await.map_err(|e| {
if e.is_timeout() {
eyre::eyre!("Request to SSV network API timed out: {e}")
eyre::eyre!("Request to SSV public API timed out: {e}")
} else {
eyre::eyre!("Error sending request to SSV network API: {e}")
eyre::eyre!("Error sending request to SSV public API: {e}")
}
})?;

// Parse the response as JSON
let body_bytes = safe_read_http_response(response).await?;
serde_json::from_slice::<SSVResponse>(&body_bytes).wrap_err("failed to parse SSV response")
serde_json::from_slice::<SSVPublicResponse>(&body_bytes)
.wrap_err("failed to parse SSV response")
}
3 changes: 2 additions & 1 deletion crates/pbs/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,8 @@ impl PbsService {
.load(
&runtime_config.id,
config.chain,
default_pbs.ssv_api_url.clone(),
default_pbs.ssv_node_api_url.clone(),
default_pbs.ssv_public_api_url.clone(),
default_pbs.rpc_url.clone(),
http_timeout,
)
Expand Down
1 change: 1 addition & 0 deletions tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ cb-signer.workspace = true
eyre.workspace = true
lh_types.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
tempfile.workspace = true
tokio.workspace = true
Expand Down
22 changes: 22 additions & 0 deletions tests/data/ssv_valid_node.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"data": [
{
"public_key": "aa370f6250d421d00437b9900407a7ad93b041aeb7259d99b55ab8b163277746680e93e841f87350737bceee46aa104d",
"index": "1311498",
"status": "active_ongoing",
"activation_epoch": "273156",
"exit_epoch": "18446744073709551615",
"owner": "5e33db0b37622f7e6b2f0654aa7b985d854ea9cb",
"committee": [
1,
2,
3,
4
],
"quorum": 0,
"partial_quorum": 0,
"graffiti": "",
"liquidated": false
}
]
}
File renamed without changes.
3 changes: 2 additions & 1 deletion tests/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod mock_relay;
pub mod mock_ssv;
pub mod mock_ssv_node;
pub mod mock_ssv_public;
pub mod mock_validator;
pub mod utils;
Loading
Loading