Skip to content

Commit f44469f

Browse files
iurii-ssvjclapis
authored andcommitted
ssv-network: SSV-node API support (#415)
Co-authored-by: Joe Clapis <jclapis@outlook.com>
1 parent 2ba2eb5 commit f44469f

15 files changed

Lines changed: 586 additions & 71 deletions

File tree

config.example.toml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,13 @@ extra_validation_enabled = false
5555
# Execution Layer RPC url to use for extra validation
5656
# OPTIONAL
5757
# rpc_url = "https://ethereum-holesky-rpc.publicnode.com"
58-
# URL of the SSV API server to use, if you have a mux that targets an SSV node operator
59-
# OPTIONAL, DEFAULT: "https://api.ssv.network/api/v4"
60-
# ssv_api_url = "https://api.ssv.network/api/v4"
58+
# URL of your local SSV node API endpoint, if you have a mux that targets an SSV node operator
59+
# OPTIONAL, DEFAULT: "http://localhost:16000/v1/"
60+
# ssv_node_api_url = "http://localhost:16000/v1/"
61+
# URL of the public SSV API server, if you have a mux that targets an SSV node operator. This is used as
62+
# a fallback if the user's own SSV node is not reachable.
63+
# OPTIONAL, DEFAULT: "https://api.ssv.network/api/v4/"
64+
# ssv_public_api_url = "https://api.ssv.network/api/v4/"
6165
# Timeout for any HTTP requests sent from the PBS module to other services, in seconds
6266
# OPTIONAL, DEFAULT: 10
6367
http_timeout_seconds = 10

crates/common/src/config/mux.rs

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ impl PbsMuxes {
6262
.load(
6363
&mux.id,
6464
chain,
65-
default_pbs.ssv_api_url.clone(),
65+
default_pbs.ssv_node_api_url.clone(),
66+
default_pbs.ssv_public_api_url.clone(),
6667
default_pbs.rpc_url.clone(),
6768
http_timeout,
6869
)
@@ -212,7 +213,8 @@ impl MuxKeysLoader {
212213
&self,
213214
mux_id: &str,
214215
chain: Chain,
215-
ssv_api_url: Url,
216+
ssv_node_api_url: Url,
217+
ssv_public_api_url: Url,
216218
rpc_url: Option<Url>,
217219
http_timeout: Duration,
218220
) -> eyre::Result<Vec<BlsPublicKey>> {
@@ -258,7 +260,8 @@ impl MuxKeysLoader {
258260
}
259261
NORegistry::SSV => {
260262
fetch_ssv_pubkeys(
261-
ssv_api_url,
263+
ssv_node_api_url,
264+
ssv_public_api_url,
262265
chain,
263266
U256::from(*node_operator_id),
264267
http_timeout,
@@ -391,11 +394,62 @@ async fn fetch_lido_registry_keys(
391394
}
392395

393396
async fn fetch_ssv_pubkeys(
394-
mut api_url: Url,
397+
node_url: Url,
398+
public_url: Url,
395399
chain: Chain,
396400
node_operator_id: U256,
397401
http_timeout: Duration,
398402
) -> eyre::Result<Vec<BlsPublicKey>> {
403+
// Try the node API first
404+
match fetch_ssv_pubkeys_from_ssv_node(node_url.clone(), node_operator_id, http_timeout).await {
405+
Ok(pubkeys) => Ok(pubkeys),
406+
Err(e) => {
407+
// Fall back to public API
408+
warn!(
409+
"failed to fetch pubkeys from SSV node API at {node_url}: {e}; falling back to public API",
410+
);
411+
fetch_ssv_pubkeys_from_public_api(public_url, chain, node_operator_id, http_timeout)
412+
.await
413+
}
414+
}
415+
}
416+
417+
/// Ensures that the SSV API URL has a trailing slash
418+
fn ensure_ssv_api_url(url: &mut Url) -> eyre::Result<()> {
419+
// Validate the URL - this appends a trailing slash if missing as efficiently as
420+
// possible
421+
if !url.path().ends_with('/') {
422+
match url.path_segments_mut() {
423+
Ok(mut segments) => segments.push(""), // Analogous to a trailing slash
424+
Err(_) => bail!("SSV API URL is not a valid base URL"),
425+
};
426+
}
427+
Ok(())
428+
}
429+
430+
/// Fetches SSV pubkeys from the user's SSV node
431+
async fn fetch_ssv_pubkeys_from_ssv_node(
432+
mut url: Url,
433+
node_operator_id: U256,
434+
http_timeout: Duration,
435+
) -> eyre::Result<Vec<BlsPublicKey>> {
436+
ensure_ssv_api_url(&mut url)?;
437+
let route = "validators";
438+
let url = url.join(route).wrap_err("failed to construct SSV API URL")?;
439+
440+
let response = request_ssv_pubkeys_from_ssv_node(url, node_operator_id, http_timeout).await?;
441+
let pubkeys = response.data.into_iter().map(|v| v.public_key).collect::<Vec<BlsPublicKey>>();
442+
Ok(pubkeys)
443+
}
444+
445+
/// Fetches SSV pubkeys from the public SSV network API with pagination
446+
async fn fetch_ssv_pubkeys_from_public_api(
447+
mut url: Url,
448+
chain: Chain,
449+
node_operator_id: U256,
450+
http_timeout: Duration,
451+
) -> eyre::Result<Vec<BlsPublicKey>> {
452+
ensure_ssv_api_url(&mut url)?;
399453
const MAX_PER_PAGE: usize = 100;
400454

401455
let chain_name = match chain {
@@ -409,22 +463,13 @@ async fn fetch_ssv_pubkeys(
409463
let mut page = 1;
410464
let mut expected_total: Option<usize> = None;
411465

412-
// Validate the URL - this appends a trailing slash if missing as efficiently as
413-
// possible
414-
if !api_url.path().ends_with('/') {
415-
match api_url.path_segments_mut() {
416-
Ok(mut segments) => segments.push(""), // Analogous to a trailing slash
417-
Err(_) => bail!("SSV API URL is not a valid base URL"),
418-
};
419-
}
420-
421466
loop {
422467
let route = format!(
423468
"{chain_name}/validators/in_operator/{node_operator_id}?perPage={MAX_PER_PAGE}&page={page}",
424469
);
425-
let url = api_url.join(&route).wrap_err("failed to construct SSV API URL")?;
470+
let url = url.join(&route).wrap_err("failed to construct SSV API URL")?;
426471

427-
let response = fetch_ssv_pubkeys_from_url(url, http_timeout).await?;
472+
let response = request_ssv_pubkeys_from_public_api(url, http_timeout).await?;
428473
let fetched = response.validators.len();
429474
if expected_total.is_none() && fetched > 0 {
430475
expected_total = Some(response.pagination.total);

crates/common/src/config/pbs.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,9 +124,12 @@ pub struct PbsConfig {
124124
pub extra_validation_enabled: bool,
125125
/// Execution Layer RPC url to use for extra validation
126126
pub rpc_url: Option<Url>,
127-
/// URL for the SSV network API
128-
#[serde(default = "default_ssv_api_url")]
129-
pub ssv_api_url: Url,
127+
/// URL for the user's own SSV node API endpoint
128+
#[serde(default = "default_ssv_node_api_url")]
129+
pub ssv_node_api_url: Url,
130+
/// URL for the public SSV network API server
131+
#[serde(default = "default_public_ssv_api_url")]
132+
pub ssv_public_api_url: Url,
130133
/// Timeout for HTTP requests in seconds
131134
#[serde(default = "default_u64::<HTTP_TIMEOUT_SECONDS_DEFAULT>")]
132135
pub http_timeout_seconds: u64,
@@ -409,7 +412,12 @@ pub async fn load_pbs_custom_config<T: DeserializeOwned>() -> Result<(PbsModuleC
409412
))
410413
}
411414

412-
/// Default URL for the SSV network API
413-
fn default_ssv_api_url() -> Url {
415+
/// Default URL for the user's SSV node API endpoint (/v1/validators).
416+
fn default_ssv_node_api_url() -> Url {
417+
Url::parse("http://localhost:16000/v1/").expect("default URL is valid")
418+
}
419+
420+
/// Default URL for the public SSV network API.
421+
fn default_public_ssv_api_url() -> Url {
414422
Url::parse("https://api.ssv.network/api/v4/").expect("default URL is valid")
415423
}

crates/common/src/interop/ssv/types.rs

Lines changed: 55 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,73 @@ use serde::{Deserialize, Deserializer, Serialize};
22

33
use crate::types::BlsPublicKey;
44

5-
/// Response from the SSV API for validators
5+
/// Response from the SSV API for validators (the new way, relies on using SSV
6+
/// node API)
67
#[derive(Deserialize, Serialize)]
7-
pub struct SSVResponse {
8+
pub struct SSVNodeResponse {
89
/// List of validators returned by the SSV API
9-
pub validators: Vec<SSVValidator>,
10+
pub data: Vec<SSVNodeValidator>,
11+
}
12+
13+
/// Representation of a validator in the SSV API
14+
#[derive(Clone)]
15+
pub struct SSVNodeValidator {
16+
/// The public key of the validator
17+
pub public_key: BlsPublicKey,
18+
}
19+
20+
impl<'de> Deserialize<'de> for SSVNodeValidator {
21+
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
22+
where
23+
D: Deserializer<'de>,
24+
{
25+
#[derive(Deserialize)]
26+
struct SSVValidator {
27+
public_key: String,
28+
}
29+
30+
let s = SSVValidator::deserialize(deserializer)?;
31+
let bytes = alloy::hex::decode(&s.public_key).map_err(serde::de::Error::custom)?;
32+
let pubkey = BlsPublicKey::deserialize(&bytes)
33+
.map_err(|e| serde::de::Error::custom(format!("invalid BLS public key: {e:?}")))?;
34+
35+
Ok(Self { public_key: pubkey })
36+
}
37+
}
38+
39+
impl Serialize for SSVNodeValidator {
40+
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
41+
where
42+
S: serde::Serializer,
43+
{
44+
#[derive(Serialize)]
45+
struct SSVValidator {
46+
public_key: String,
47+
}
48+
49+
let s = SSVValidator { public_key: self.public_key.as_hex_string() };
50+
s.serialize(serializer)
51+
}
52+
}
53+
54+
/// Response from the SSV API for validators from the public api.ssv.network URL
55+
#[derive(Deserialize, Serialize)]
56+
pub struct SSVPublicResponse {
57+
/// List of validators returned by the SSV API
58+
pub validators: Vec<SSVPublicValidator>,
1059

1160
/// Pagination information
1261
pub pagination: SSVPagination,
1362
}
1463

1564
/// Representation of a validator in the SSV API
1665
#[derive(Clone)]
17-
pub struct SSVValidator {
66+
pub struct SSVPublicValidator {
1867
/// The public key of the validator
1968
pub pubkey: BlsPublicKey,
2069
}
2170

22-
impl<'de> Deserialize<'de> for SSVValidator {
71+
impl<'de> Deserialize<'de> for SSVPublicValidator {
2372
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
2473
where
2574
D: Deserializer<'de>,
@@ -38,7 +87,7 @@ impl<'de> Deserialize<'de> for SSVValidator {
3887
}
3988
}
4089

41-
impl Serialize for SSVValidator {
90+
impl Serialize for SSVPublicValidator {
4291
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
4392
where
4493
S: serde::Serializer,
Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,52 @@
11
use std::time::Duration;
22

3+
use alloy::primitives::U256;
34
use eyre::Context;
5+
use serde_json::json;
46
use url::Url;
57

6-
use crate::{config::safe_read_http_response, interop::ssv::types::SSVResponse};
8+
use crate::{
9+
config::safe_read_http_response,
10+
interop::ssv::types::{SSVNodeResponse, SSVPublicResponse},
11+
};
712

8-
pub async fn fetch_ssv_pubkeys_from_url(
13+
pub async fn request_ssv_pubkeys_from_ssv_node(
914
url: Url,
15+
node_operator_id: U256,
1016
http_timeout: Duration,
11-
) -> eyre::Result<SSVResponse> {
17+
) -> eyre::Result<SSVNodeResponse> {
18+
let client = reqwest::ClientBuilder::new().timeout(http_timeout).build()?;
19+
let body = json!({
20+
"operators": [node_operator_id]
21+
});
22+
let response = client.get(url).json(&body).send().await.map_err(|e| {
23+
if e.is_timeout() {
24+
eyre::eyre!("Request to SSV node timed out: {e}")
25+
} else {
26+
eyre::eyre!("Error sending request to SSV node: {e}")
27+
}
28+
})?;
29+
30+
// Parse the response as JSON
31+
let body_bytes = safe_read_http_response(response).await?;
32+
serde_json::from_slice::<SSVNodeResponse>(&body_bytes).wrap_err("failed to parse SSV response")
33+
}
34+
35+
pub async fn request_ssv_pubkeys_from_public_api(
36+
url: Url,
37+
http_timeout: Duration,
38+
) -> eyre::Result<SSVPublicResponse> {
1239
let client = reqwest::ClientBuilder::new().timeout(http_timeout).build()?;
1340
let response = client.get(url).send().await.map_err(|e| {
1441
if e.is_timeout() {
15-
eyre::eyre!("Request to SSV network API timed out: {e}")
42+
eyre::eyre!("Request to SSV public API timed out: {e}")
1643
} else {
17-
eyre::eyre!("Error sending request to SSV network API: {e}")
44+
eyre::eyre!("Error sending request to SSV public API: {e}")
1845
}
1946
})?;
2047

2148
// Parse the response as JSON
2249
let body_bytes = safe_read_http_response(response).await?;
23-
serde_json::from_slice::<SSVResponse>(&body_bytes).wrap_err("failed to parse SSV response")
50+
serde_json::from_slice::<SSVPublicResponse>(&body_bytes)
51+
.wrap_err("failed to parse SSV response")
2452
}

crates/pbs/src/service.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,8 @@ impl PbsService {
160160
.load(
161161
&runtime_config.id,
162162
config.chain,
163-
default_pbs.ssv_api_url.clone(),
163+
default_pbs.ssv_node_api_url.clone(),
164+
default_pbs.ssv_public_api_url.clone(),
164165
default_pbs.rpc_url.clone(),
165166
http_timeout,
166167
)

tests/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ cb-signer.workspace = true
1313
eyre.workspace = true
1414
lh_types.workspace = true
1515
reqwest.workspace = true
16+
serde.workspace = true
1617
serde_json.workspace = true
1718
tempfile.workspace = true
1819
tokio.workspace = true

tests/data/ssv_valid_node.json

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
"data": [
3+
{
4+
"public_key": "aa370f6250d421d00437b9900407a7ad93b041aeb7259d99b55ab8b163277746680e93e841f87350737bceee46aa104d",
5+
"index": "1311498",
6+
"status": "active_ongoing",
7+
"activation_epoch": "273156",
8+
"exit_epoch": "18446744073709551615",
9+
"owner": "5e33db0b37622f7e6b2f0654aa7b985d854ea9cb",
10+
"committee": [
11+
1,
12+
2,
13+
3,
14+
4
15+
],
16+
"quorum": 0,
17+
"partial_quorum": 0,
18+
"graffiti": "",
19+
"liquidated": false
20+
}
21+
]
22+
}

tests/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
pub mod mock_relay;
2-
pub mod mock_ssv;
2+
pub mod mock_ssv_node;
3+
pub mod mock_ssv_public;
34
pub mod mock_validator;
45
pub mod utils;

0 commit comments

Comments
 (0)