use {
anyhow::bail,
pyth_lazer_protocol::api::SignedGuardianSetUpgrade,
serde::{Deserialize, Serialize},
std::{sync::Arc, time::Duration},
tracing::warn,
url::Url,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PythLazerRouterClientConfig {
pub urls: Vec<Url>,
#[serde(with = "humantime_serde", default = "default_request_timeout")]
pub request_timeout: Duration,
pub access_token: String,
}
fn default_request_timeout() -> Duration {
Duration::from_secs(15)
}
#[derive(Debug, Clone)]
pub struct PythLazerRouterClient {
config: Arc<PythLazerRouterClientConfig>,
client: reqwest::Client,
}
impl PythLazerRouterClient {
pub fn new(config: PythLazerRouterClientConfig) -> anyhow::Result<Self> {
if config.urls.is_empty() {
bail!("no router urls provided");
}
Ok(Self {
client: reqwest::Client::builder()
.timeout(config.request_timeout)
.build()?,
config: Arc::new(config),
})
}
pub async fn guardian_set_upgrade(&self) -> anyhow::Result<Option<SignedGuardianSetUpgrade>> {
for url in &self.config.urls {
match self.request_guardian_set_upgrade(url).await {
Ok(output) => return Ok(output),
Err(err) => {
warn!(?url, ?err, "failed to fetch from router, trying next url");
}
}
}
bail!(
"failed to fetch data from any router urls ({:?})",
self.config.urls
);
}
async fn request_guardian_set_upgrade(
&self,
url: &Url,
) -> anyhow::Result<Option<SignedGuardianSetUpgrade>> {
let url = url.join("v1/guardian_set_upgrade")?;
let response = self
.client
.get(url.clone())
.bearer_auth(&self.config.access_token)
.send()
.await?;
let response = response.error_for_status()?;
let upgrade = response.json::<Option<SignedGuardianSetUpgrade>>().await?;
Ok(upgrade)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_guardian_set_upgrade_success() {
let server = httpmock::MockServer::start();
let json_body = serde_json::json!({
"current_guardian_set_index": 1,
"new_guardian_set_index": 2,
"new_guardian_keys": ["aabbccdd"],
"body": "deadbeef",
"signature": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f4041"
});
server.mock(|when, then| {
when.method(httpmock::Method::GET)
.path("/v1/guardian_set_upgrade")
.header("Authorization", "Bearer test-token");
then.status(200)
.header("Content-Type", "application/json")
.json_body(json_body);
});
let client = PythLazerRouterClient::new(PythLazerRouterClientConfig {
urls: vec![Url::parse(&server.base_url()).unwrap()],
request_timeout: Duration::from_secs(5),
access_token: "test-token".to_string(),
})
.unwrap();
let result = client.guardian_set_upgrade().await.unwrap();
assert!(result.is_some());
let upgrade = result.unwrap();
assert_eq!(upgrade.current_guardian_set_index, 1);
assert_eq!(upgrade.new_guardian_set_index, 2);
assert_eq!(upgrade.new_guardian_keys.len(), 1);
}
#[tokio::test]
async fn test_guardian_set_upgrade_not_found() {
let server = httpmock::MockServer::start();
server.mock(|when, then| {
when.method(httpmock::Method::GET)
.path("/v1/guardian_set_upgrade");
then.status(200)
.header("Content-Type", "application/json")
.body("null");
});
let client = PythLazerRouterClient::new(PythLazerRouterClientConfig {
urls: vec![Url::parse(&server.base_url()).unwrap()],
request_timeout: Duration::from_secs(5),
access_token: "test-token".to_string(),
})
.unwrap();
let result = client.guardian_set_upgrade().await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_guardian_set_upgrade_fallback_to_second_url() {
let server1 = httpmock::MockServer::start();
let server2 = httpmock::MockServer::start();
server1.mock(|when, then| {
when.method(httpmock::Method::GET)
.path("/v1/guardian_set_upgrade");
then.status(400);
});
let json_body = serde_json::json!({
"current_guardian_set_index": 10,
"new_guardian_set_index": 11,
"new_guardian_keys": ["ff00ff00"],
"body": "cafe",
"signature": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f4041"
});
server2.mock(|when, then| {
when.method(httpmock::Method::GET)
.path("/v1/guardian_set_upgrade");
then.status(200)
.header("Content-Type", "application/json")
.json_body(json_body);
});
let client = PythLazerRouterClient::new(PythLazerRouterClientConfig {
urls: vec![
Url::parse(&server1.base_url()).unwrap(),
Url::parse(&server2.base_url()).unwrap(),
],
request_timeout: Duration::from_secs(5),
access_token: "test-token".to_string(),
})
.unwrap();
let result = client.guardian_set_upgrade().await.unwrap();
assert!(result.is_some());
let upgrade = result.unwrap();
assert_eq!(upgrade.current_guardian_set_index, 10);
assert_eq!(upgrade.new_guardian_set_index, 11);
}
#[test]
fn test_new_no_urls_returns_error() {
let result = PythLazerRouterClient::new(PythLazerRouterClientConfig {
urls: vec![],
request_timeout: Duration::from_secs(5),
access_token: "test-token".to_string(),
});
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("no router urls provided")
);
}
}