stakewiz-rs 0.1.0

Unofficial Rust client for the Stakewiz API - Solana validator analytics
Documentation
use reqwest::Client;

use crate::error::{Result, StakewizError};
use crate::models::*;
use crate::params::QueryParams;

const BASE_URL: &str = "https://api.stakewiz.com";

/// Async client for the Stakewiz API.
///
/// # Example
/// ```no_run
/// # async fn example() -> stakewiz_rs::error::Result<()> {
/// let client = stakewiz_rs::StakewizClient::new();
/// let validator = client.get_validator("GE6atKoWiQ2pt3zL7N13pjNHjdLVys8LinG8qeJLcAiL").await?;
/// println!("{}: APY {:?}", validator.name.unwrap_or_default(), validator.apy_estimate);
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone)]
pub struct StakewizClient {
    http: Client,
    base_url: String,
}

impl Default for StakewizClient {
    fn default() -> Self {
        Self::new()
    }
}

impl StakewizClient {
    /// Create a new client with default settings.
    pub fn new() -> Self {
        Self {
            http: Client::new(),
            base_url: BASE_URL.to_string(),
        }
    }

    /// Create a client with a custom `reqwest::Client` (e.g. for custom timeouts).
    pub fn with_http_client(http: Client) -> Self {
        Self {
            http,
            base_url: BASE_URL.to_string(),
        }
    }

    /// Override the base URL (useful for testing).
    pub fn with_base_url(mut self, url: &str) -> Self {
        self.base_url = url.trim_end_matches('/').to_string();
        self
    }

    // ─── Internal helpers ───────────────────────────────────────────────

    async fn get_json<T: serde::de::DeserializeOwned>(&self, path: &str) -> Result<T> {
        let url = format!("{}{}", self.base_url, path);
        let resp = self.http.get(&url).send().await?;
        let status = resp.status();
        if !status.is_success() {
            let body = resp.text().await.unwrap_or_default();
            return Err(StakewizError::Api(format!("HTTP {status}: {body}")));
        }
        Ok(resp.json::<T>().await?)
    }

    async fn get_json_with_params<T: serde::de::DeserializeOwned>(
        &self,
        path: &str,
        params: &QueryParams,
    ) -> Result<T> {
        let url = format!("{}{}", self.base_url, path);
        let pairs = params.to_query_pairs();
        let resp = self.http.get(&url).query(&pairs).send().await?;
        let status = resp.status();
        if !status.is_success() {
            let body = resp.text().await.unwrap_or_default();
            return Err(StakewizError::Api(format!("HTTP {status}: {body}")));
        }
        Ok(resp.json::<T>().await?)
    }

    // ─── Validators ─────────────────────────────────────────────────────

    /// Get all validators (sorted by Wiz Score descending by default).
    ///
    /// **Note**: Response is ~2.5-3MB. Data updates every minute.
    pub async fn get_validators(&self, params: Option<&QueryParams>) -> Result<Vec<Validator>> {
        match params {
            Some(p) => self.get_json_with_params("/validators", p).await,
            None => self.get_json("/validators").await,
        }
    }

    /// Get a single validator by vote account pubkey.
    pub async fn get_validator(&self, vote_identity: &str) -> Result<Validator> {
        self.get_json(&format!("/validator/{vote_identity}")).await
    }

    /// Get delinquency history for a validator (default: 30 days).
    pub async fn get_validator_delinquencies(
        &self,
        vote_identity: &str,
        params: Option<&QueryParams>,
    ) -> Result<Vec<Delinquency>> {
        let path = format!("/validator_delinquencies/{vote_identity}");
        match params {
            Some(p) => self.get_json_with_params(&path, p).await,
            None => self.get_json(&path).await,
        }
    }

    /// Get total stake per epoch for a validator (default: 30 epochs).
    pub async fn get_validator_total_stakes(
        &self,
        vote_identity: &str,
        params: Option<&QueryParams>,
    ) -> Result<Vec<EpochStake>> {
        let path = format!("/validator_total_stakes/{vote_identity}");
        match params {
            Some(p) => self.get_json_with_params(&path, p).await,
            None => self.get_json(&path).await,
        }
    }

    /// Get stake changes for current epoch (summary).
    pub async fn get_validator_epoch_stakes_summary(
        &self,
        vote_identity: &str,
    ) -> Result<Vec<StakeChanges>> {
        self.get_json(&format!("/validator_epoch_stakes/{vote_identity}"))
            .await
    }

    /// Get all stake accounts for a validator (includes deactivated).
    pub async fn get_validator_stakes(
        &self,
        vote_identity: &str,
    ) -> Result<Vec<StakeAccount>> {
        self.get_json(&format!("/validator_stakes/{vote_identity}"))
            .await
    }

    /// Get historic Wiz Scores (30 days) including cluster average.
    pub async fn get_validator_wiz_scores(
        &self,
        vote_identity: &str,
    ) -> Result<Vec<HistoricWizScore>> {
        self.get_json(&format!("/validator_wiz_scores/{vote_identity}"))
            .await
    }

    /// Get historic vote success rates (default: 30 days).
    pub async fn get_validator_vote_success(
        &self,
        vote_identity: &str,
        params: Option<&QueryParams>,
    ) -> Result<Vec<VoteSuccess>> {
        let path = format!("/validator_vote_success/{vote_identity}");
        match params {
            Some(p) => self.get_json_with_params(&path, p).await,
            None => self.get_json(&path).await,
        }
    }

    /// Get historic skip rates (default: 30 days).
    pub async fn get_validator_skip_rate(
        &self,
        vote_identity: &str,
        params: Option<&QueryParams>,
    ) -> Result<Vec<SkipRate>> {
        let path = format!("/validator_skip_rate/{vote_identity}");
        match params {
            Some(p) => self.get_json_with_params(&path, p).await,
            None => self.get_json(&path).await,
        }
    }

    // ─── Cluster Statistics ─────────────────────────────────────────────

    /// Get cluster-wide average statistics.
    pub async fn get_cluster_stats(&self) -> Result<ClusterStats> {
        self.get_json("/cluster_stats").await
    }

    // ─── Wiz Score ──────────────────────────────────────────────────────

    /// Get current Wiz Score weightings.
    pub async fn get_wiz_score_weightings(&self) -> Result<WizScoreWeightings> {
        self.get_json("/wiz_score").await
    }

    // ─── Epoch ──────────────────────────────────────────────────────────

    /// Get current epoch info.
    pub async fn get_epoch_info(&self) -> Result<EpochInfo> {
        self.get_json("/epoch_info").await
    }

    /// Get info for a specific historical epoch. Returns `None` if not found.
    pub async fn get_epoch_history(&self, epoch: u64) -> Result<Option<EpochHistory>> {
        let url = format!("{}/epoch_history/{epoch}", self.base_url);
        let resp = self.http.get(&url).send().await?;
        let text = resp.text().await?;

        // API returns literal `false` when epoch not found
        if text.trim() == "false" {
            return Ok(None);
        }
        Ok(Some(serde_json::from_str(&text)?))
    }

    /// Get all available historical epochs.
    pub async fn get_all_epochs_history(&self) -> Result<Vec<EpochHistoryEntry>> {
        self.get_json("/all_epochs_history").await
    }

    // ─── Other ──────────────────────────────────────────────────────────

    /// Get validators that a wallet (withdraw authority) has active delegations to.
    /// Returns a list of vote account pubkeys.
    pub async fn get_stake_validators_by_withdraw_authority(
        &self,
        pubkey: &str,
    ) -> Result<Vec<String>> {
        self.get_json(&format!(
            "/stake_validators_by_withdraw_authority/{pubkey}"
        ))
        .await
    }
}