kobe_client/
client.rs

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