kobe_client/
client.rs

1use std::time::Duration;
2
3use reqwest::{Client, Method, Response, StatusCode};
4use serde::{Serialize, de::DeserializeOwned};
5
6use crate::{
7    config::Config,
8    error::KobeApiError,
9    types::{bam_epoch_metrics::BamEpochMetricsResponse, bam_validators::BamValidatorsResponse, *},
10};
11
12/// Main client for interacting with Jito APIs
13#[derive(Debug, Clone)]
14pub struct KobeClient {
15    client: Client,
16    config: Config,
17}
18
19impl KobeClient {
20    /// Create a new Jito API client with the given configuration
21    pub fn new(config: Config) -> Self {
22        let client = Client::builder()
23            .timeout(config.timeout)
24            .user_agent(&config.user_agent)
25            .build()
26            .expect("Failed to build HTTP client");
27
28        Self { client, config }
29    }
30
31    /// Create a client with mainnet defaults
32    pub fn mainnet() -> Self {
33        Self::new(Config::mainnet())
34    }
35
36    /// Create a client with testnet defaults
37    pub fn testnet() -> Self {
38        Self::new(Config::testnet())
39    }
40
41    /// Get the base URL
42    pub fn base_url(&self) -> &str {
43        &self.config.base_url
44    }
45
46    /// Make a GET request
47    async fn get<T: DeserializeOwned>(
48        &self,
49        endpoint: &str,
50        query: &str,
51    ) -> Result<T, KobeApiError> {
52        let url = format!(
53            "{}/api/{}{}{}",
54            self.config.base_url,
55            crate::API_VERSION,
56            endpoint,
57            query
58        );
59        self.request(Method::GET, &url, None::<&()>).await
60    }
61
62    /// Make a POST request
63    async fn post<B: Serialize, T: DeserializeOwned>(
64        &self,
65        endpoint: &str,
66        body: Option<&B>,
67    ) -> Result<T, KobeApiError> {
68        let url = format!(
69            "{}/api/{}{}",
70            self.config.base_url,
71            crate::API_VERSION,
72            endpoint
73        );
74        self.request(Method::POST, &url, body).await
75    }
76
77    /// Make an HTTP request with optional retry logic
78    async fn request<B: Serialize, T: DeserializeOwned>(
79        &self,
80        method: Method,
81        url: &str,
82        body: Option<&B>,
83    ) -> Result<T, KobeApiError> {
84        let mut retries = 0;
85        let max_retries = if self.config.retry_enabled {
86            self.config.max_retries
87        } else {
88            0
89        };
90
91        loop {
92            let mut request = self.client.request(method.clone(), url);
93
94            if let Some(body) = body {
95                request = request.json(body);
96            }
97
98            let response = request.send().await?;
99
100            match self.handle_response(response).await {
101                Ok(data) => return Ok(data),
102                Err(e) => {
103                    if retries >= max_retries || !self.should_retry(&e) {
104                        return Err(e);
105                    }
106                    retries += 1;
107                    // Exponential backoff
108                    let delay = Duration::from_millis(100 * 2u64.pow(retries));
109                    tokio::time::sleep(delay).await;
110                }
111            }
112        }
113    }
114
115    /// Handle HTTP response
116    async fn handle_response<T: DeserializeOwned>(
117        &self,
118        response: Response,
119    ) -> Result<T, KobeApiError> {
120        let status = response.status();
121
122        if status.is_success() {
123            response.json::<T>().await.map_err(Into::into)
124        } else {
125            let status_code = status.as_u16();
126            let error_text = response
127                .text()
128                .await
129                .unwrap_or_else(|_| "Unknown error".to_string());
130
131            match status {
132                StatusCode::NOT_FOUND => Err(KobeApiError::NotFound(error_text)),
133                StatusCode::TOO_MANY_REQUESTS => Err(KobeApiError::RateLimitExceeded),
134                StatusCode::REQUEST_TIMEOUT => Err(KobeApiError::Timeout),
135                _ => Err(KobeApiError::api_error(status_code, error_text)),
136            }
137        }
138    }
139
140    /// Determine if an error should trigger a retry
141    fn should_retry(&self, error: &KobeApiError) -> bool {
142        matches!(error, KobeApiError::Timeout | KobeApiError::HttpError(_))
143    }
144
145    /// Get staker rewards
146    ///
147    /// Retrieves individual claimable MEV and priority fee rewards from the tip distribution merkle trees.
148    ///
149    /// # Arguments
150    ///
151    /// * `limit` - Optional limit on the number of results (default: API default)
152    ///
153    /// # Example
154    ///
155    /// ```no_run
156    /// # use kobe_client::client::KobeClient;
157    /// # #[tokio::main]
158    /// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
159    /// let client = KobeClient::mainnet();
160    /// let rewards = client.get_staker_rewards(Some(10)).await?;
161    /// # Ok(())
162    /// # }
163    /// ```
164    pub async fn get_staker_rewards(
165        &self,
166        limit: Option<u32>,
167    ) -> Result<StakerRewardsResponse, KobeApiError> {
168        let query = if let Some(limit) = limit {
169            format!("?limit={}", limit)
170        } else {
171            String::new()
172        };
173        self.get("/staker_rewards", &query).await
174    }
175
176    /// Get staker rewards with full query parameters
177    pub async fn get_staker_rewards_with_params(
178        &self,
179        params: &QueryParams,
180    ) -> Result<StakerRewardsResponse, KobeApiError> {
181        self.get("/staker_rewards", &params.to_query_string()).await
182    }
183
184    /// Get validator rewards for a specific epoch
185    ///
186    /// Retrieves aggregated MEV and priority fee rewards data per validator.
187    ///
188    /// # Arguments
189    ///
190    /// * `epoch` - Epoch number (optional, defaults to latest)
191    /// * `limit` - Optional limit on the number of results
192    pub async fn get_validator_rewards(
193        &self,
194        epoch: Option<u64>,
195        limit: Option<u32>,
196    ) -> Result<ValidatorRewardsResponse, KobeApiError> {
197        let mut params = Vec::new();
198
199        if let Some(epoch) = epoch {
200            params.push(format!("epoch={}", epoch));
201        }
202        if let Some(limit) = limit {
203            params.push(format!("limit={}", limit));
204        }
205
206        let query = if params.is_empty() {
207            String::new()
208        } else {
209            format!("?{}", params.join("&"))
210        };
211
212        self.get("/validator_rewards", &query).await
213    }
214
215    /// Get all validators for a given epoch
216    ///
217    /// Returns validator state for a given epoch (defaults to latest).
218    ///
219    /// # Arguments
220    ///
221    /// * `epoch` - Optional epoch number (defaults to latest)
222    pub async fn get_validators(
223        &self,
224        epoch: Option<u64>,
225    ) -> Result<ValidatorsResponse, KobeApiError> {
226        if let Some(epoch) = epoch {
227            self.post("/validators", Some(&EpochRequest { epoch }))
228                .await
229        } else {
230            self.post::<EpochRequest, _>("/validators", None).await
231        }
232    }
233
234    /// Get JitoSOL stake pool validators for a given epoch
235    ///
236    /// Returns only validators that are actively part of the JitoSOL validator set.
237    pub async fn get_jitosol_validators(
238        &self,
239        epoch: Option<u64>,
240    ) -> Result<ValidatorsResponse, KobeApiError> {
241        if let Some(epoch) = epoch {
242            self.post("/jitosol_validators", Some(&EpochRequest { epoch }))
243                .await
244        } else {
245            self.post::<EpochRequest, _>("/jitosol_validators", None)
246                .await
247        }
248    }
249
250    /// Get historical data for a single validator
251    ///
252    /// Returns historical reward data for a validator, sorted by epoch (descending).
253    ///
254    /// # Arguments
255    ///
256    /// * `vote_account` - The validator's vote account public key
257    pub async fn get_validator_info_by_vote_account(
258        &self,
259        vote_account: &str,
260    ) -> Result<Vec<ValidatorByVoteAccount>, KobeApiError> {
261        self.get(&format!("/validators/{}", vote_account), "").await
262    }
263
264    /// Get MEV rewards network statistics for an epoch
265    ///
266    /// Returns network-level statistics including total MEV, stake weight, and reward per lamport.
267    ///
268    /// # Arguments
269    ///
270    /// * `epoch` - Optional epoch number (defaults to latest)
271    pub async fn get_mev_rewards(&self, epoch: Option<u64>) -> Result<MevRewards, KobeApiError> {
272        if let Some(epoch) = epoch {
273            self.post("/mev_rewards", Some(&EpochRequest { epoch }))
274                .await
275        } else {
276            // GET request for latest epoch
277            self.get("/mev_rewards", "").await
278        }
279    }
280
281    /// Get daily MEV rewards
282    ///
283    /// Returns aggregated MEV rewards per calendar day.
284    pub async fn get_daily_mev_rewards(&self) -> Result<Vec<DailyMevRewards>, KobeApiError> {
285        self.get("/daily_mev_rewards", "").await
286    }
287
288    /// Get Jito stake over time
289    ///
290    /// Returns a map of epoch to percentage of all Solana stake delegated to Jito-running validators.
291    pub async fn get_jito_stake_over_time(&self) -> Result<JitoStakeOverTime, KobeApiError> {
292        self.get("/jito_stake_over_time", "").await
293    }
294
295    /// Get MEV commission average over time
296    ///
297    /// Returns stake-weighted average MEV commission along with other metrics.
298    pub async fn get_mev_commission_average_over_time(
299        &self,
300    ) -> Result<MevCommissionAverageOverTime, KobeApiError> {
301        self.get("/mev_commission_average_over_time", "").await
302    }
303
304    /// Get JitoSOL to SOL exchange ratio over time
305    ///
306    /// # Arguments
307    ///
308    /// * `start` - Start datetime for the range
309    /// * `end` - End datetime for the range
310    pub async fn get_jitosol_sol_ratio(
311        &self,
312        start: chrono::DateTime<chrono::Utc>,
313        end: chrono::DateTime<chrono::Utc>,
314    ) -> Result<JitoSolRatio, KobeApiError> {
315        let request = RangeRequest {
316            range_filter: RangeFilter { start, end },
317        };
318        self.post("/jitosol_sol_ratio", Some(&request)).await
319    }
320
321    /// Get stake pool statistics
322    ///
323    /// Returns stake pool analytics including TVL, APY, validator count, supply metrics,
324    /// and aggregated MEV rewards over time.
325    pub async fn get_stake_pool_stats(
326        &self,
327        request: Option<&StakePoolStatsRequest>,
328    ) -> Result<StakePoolStats, KobeApiError> {
329        if let Some(req) = request {
330            self.post("/stake_pool_stats", Some(req)).await
331        } else {
332            // GET request for default (last 7 days)
333            self.get("/stake_pool_stats", "").await
334        }
335    }
336
337    /// Get the current epoch from the latest MEV rewards data
338    pub async fn get_current_epoch(&self) -> Result<u64, KobeApiError> {
339        let mev_rewards = self.get_mev_rewards(None).await?;
340        Ok(mev_rewards.epoch)
341    }
342
343    /// Get all validators currently running Jito
344    pub async fn get_jito_validators(&self) -> Result<Vec<ValidatorInfo>, KobeApiError> {
345        let response: ValidatorsResponse = self.get("/validators", "").await?;
346        Ok(response
347            .validators
348            .into_iter()
349            .filter(|v| v.running_jito)
350            .collect())
351    }
352
353    // Get validators sorted by MEV rewards
354    // pub async fn get_validators_by_mev_rewards(
355    //     &self,
356    //     epoch: Option<u64>,
357    //     limit: usize,
358    // ) -> Result<Vec<ValidatorInfo>, KobeApiError> {
359    //     let mut response = self.get_validators(epoch).await?;
360    //     response
361    //         .validators
362    //         .sort_by(|a, b| b.mev_rewards.cmp(&a.mev_rewards));
363    //     Ok(response.validators.into_iter().take(limit).collect())
364    // }
365
366    /// Get validators sorted by active stake
367    pub async fn get_validators_by_stake(
368        &self,
369        epoch: Option<u64>,
370        limit: usize,
371    ) -> Result<Vec<ValidatorInfo>, KobeApiError> {
372        let mut response = self.get_validators(epoch).await?;
373        response
374            .validators
375            .sort_by(|a, b| b.active_stake.cmp(&a.active_stake));
376        Ok(response.validators.into_iter().take(limit).collect())
377    }
378
379    /// Check if a validator is running Jito
380    pub async fn is_validator_running_jito(
381        &self,
382        vote_account: &str,
383    ) -> Result<bool, KobeApiError> {
384        let response = self.get_validators(None).await?;
385        Ok(response
386            .validators
387            .iter()
388            .find(|v| v.vote_account == vote_account)
389            .map(|v| v.running_jito)
390            .unwrap_or(false))
391    }
392
393    // Get validator MEV commission
394    // pub async fn get_validator_mev_commission(
395    //     &self,
396    //     vote_account: &str,
397    // ) -> Result<Option<u16>, KobeApiError> {
398    //     let response = self.get_validators(None).await?;
399    //     Ok(response
400    //         .validators
401    //         .iter()
402    //         .find(|v| v.vote_account == vote_account)
403    //         .map(|v| v.mev_commission_bps))
404    // }
405
406    /// Calculate total MEV rewards for a time period
407    pub async fn calculate_total_mev_rewards(
408        &self,
409        start_epoch: u64,
410        end_epoch: u64,
411    ) -> Result<u64, KobeApiError> {
412        let mut total = 0u64;
413
414        for epoch in start_epoch..=end_epoch {
415            if let Ok(mev_rewards) = self.get_mev_rewards(Some(epoch)).await {
416                total = total.saturating_add(mev_rewards.total_network_mev_lamports);
417            }
418        }
419
420        Ok(total)
421    }
422
423    /// Get BAM Delegation Blacklist
424    ///
425    /// Returns bam delegation blacklist
426    pub async fn get_bam_delegation_blacklist(
427        &self,
428    ) -> Result<Vec<BamDelegationBlacklistEntry>, KobeApiError> {
429        self.get("/bam_delegation_blacklist", "").await
430    }
431
432    /// Get BAM Epoch Metrics
433    ///
434    /// Returns bam epoch metrics
435    pub async fn get_bam_epoch_metrics(
436        &self,
437        epoch: u64,
438    ) -> Result<BamEpochMetricsResponse, KobeApiError> {
439        let query = format!("?epoch={epoch}");
440        self.get("/bam_epoch_metrics", &query).await
441    }
442
443    /// Get BAM Validators
444    ///
445    /// Returns bam validators
446    pub async fn get_bam_validators(
447        &self,
448        epoch: u64,
449    ) -> Result<BamValidatorsResponse, KobeApiError> {
450        let query = format!("?epoch={epoch}");
451        self.get("/bam_validators", &query).await
452    }
453}
454
455#[cfg(test)]
456mod tests {
457    use std::time::Duration;
458
459    use crate::{client_builder::KobeApiClientBuilder, types::QueryParams};
460
461    use super::Config;
462
463    #[test]
464    fn test_config_builder() {
465        let config = Config::mainnet()
466            .with_timeout(Duration::from_secs(60))
467            .with_user_agent("test-agent")
468            .with_retry(false);
469
470        assert_eq!(config.timeout, Duration::from_secs(60));
471        assert_eq!(config.user_agent, "test-agent");
472        assert!(!config.retry_enabled);
473    }
474
475    #[test]
476    fn test_query_params() {
477        let params = QueryParams::default().limit(10).offset(20).epoch(600);
478
479        let query = params.to_query_string();
480        assert!(query.contains("limit=10"));
481        assert!(query.contains("offset=20"));
482        assert!(query.contains("epoch=600"));
483    }
484
485    #[test]
486    fn test_client_builder() {
487        let client = KobeApiClientBuilder::new()
488            .timeout(Duration::from_secs(45))
489            .retry(true)
490            .max_retries(5)
491            .build();
492
493        assert_eq!(client.config.timeout, Duration::from_secs(45));
494        assert!(client.config.retry_enabled);
495        assert_eq!(client.config.max_retries, 5);
496    }
497}