Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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")
}
69 changes: 59 additions & 10 deletions crates/common/src/interop/ssv/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,34 +2,83 @@ 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>,
{
#[derive(Deserialize)]
struct SSVValidator {
struct SSVValidatorOld {
public_key: String,
}

let s = SSVValidator::deserialize(deserializer)?;
let s = SSVValidatorOld::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:?}")))?;
Expand All @@ -38,17 +87,17 @@ 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,
{
#[derive(Serialize)]
struct SSVValidator {
struct SSVValidatorOld {
public_key: String,
}

let s = SSVValidator { public_key: self.pubkey.as_hex_string() };
let s = SSVValidatorOld { public_key: self.pubkey.as_hex_string() };
s.serialize(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