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 for an epoch
313    ///
314    /// # Arguments
315    ///
316    /// * `epoch` - Optional epoch number (defaults to latest)
317    pub async fn get_stake_pool_stats(
318        &self,
319        epoch: Option<u64>,
320    ) -> Result<StakePoolStats, KobeApiError> {
321        if let Some(epoch) = epoch {
322            self.post("/stake_pool_stats", Some(&EpochRequest { epoch }))
323                .await
324        } else {
325            self.get("/stake_pool_stats", "").await
326        }
327    }
328
329    /// Get the current epoch from the latest MEV rewards data
330    pub async fn get_current_epoch(&self) -> Result<u64, KobeApiError> {
331        let mev_rewards = self.get_mev_rewards(None).await?;
332        Ok(mev_rewards.epoch)
333    }
334
335    /// Get all validators currently running Jito
336    pub async fn get_jito_validators(&self) -> Result<Vec<ValidatorInfo>, KobeApiError> {
337        let response: ValidatorsResponse = self.get("/validators", "").await?;
338        Ok(response
339            .validators
340            .into_iter()
341            .filter(|v| v.running_jito)
342            .collect())
343    }
344
345    // Get validators sorted by MEV rewards
346    // pub async fn get_validators_by_mev_rewards(
347    //     &self,
348    //     epoch: Option<u64>,
349    //     limit: usize,
350    // ) -> Result<Vec<ValidatorInfo>, KobeApiError> {
351    //     let mut response = self.get_validators(epoch).await?;
352    //     response
353    //         .validators
354    //         .sort_by(|a, b| b.mev_rewards.cmp(&a.mev_rewards));
355    //     Ok(response.validators.into_iter().take(limit).collect())
356    // }
357
358    /// Get validators sorted by active stake
359    pub async fn get_validators_by_stake(
360        &self,
361        epoch: Option<u64>,
362        limit: usize,
363    ) -> Result<Vec<ValidatorInfo>, KobeApiError> {
364        let mut response = self.get_validators(epoch).await?;
365        response
366            .validators
367            .sort_by(|a, b| b.active_stake.cmp(&a.active_stake));
368        Ok(response.validators.into_iter().take(limit).collect())
369    }
370
371    /// Check if a validator is running Jito
372    pub async fn is_validator_running_jito(
373        &self,
374        vote_account: &str,
375    ) -> Result<bool, KobeApiError> {
376        let response = self.get_validators(None).await?;
377        Ok(response
378            .validators
379            .iter()
380            .find(|v| v.vote_account == vote_account)
381            .map(|v| v.running_jito)
382            .unwrap_or(false))
383    }
384
385    // Get validator MEV commission
386    // pub async fn get_validator_mev_commission(
387    //     &self,
388    //     vote_account: &str,
389    // ) -> Result<Option<u16>, KobeApiError> {
390    //     let response = self.get_validators(None).await?;
391    //     Ok(response
392    //         .validators
393    //         .iter()
394    //         .find(|v| v.vote_account == vote_account)
395    //         .map(|v| v.mev_commission_bps))
396    // }
397
398    /// Calculate total MEV rewards for a time period
399    pub async fn calculate_total_mev_rewards(
400        &self,
401        start_epoch: u64,
402        end_epoch: u64,
403    ) -> Result<u64, KobeApiError> {
404        let mut total = 0u64;
405
406        for epoch in start_epoch..=end_epoch {
407            if let Ok(mev_rewards) = self.get_mev_rewards(Some(epoch)).await {
408                total = total.saturating_add(mev_rewards.total_network_mev_lamports);
409            }
410        }
411
412        Ok(total)
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use std::time::Duration;
419
420    use crate::{client_builder::KobeApiClientBuilder, types::QueryParams};
421
422    use super::Config;
423
424    #[test]
425    fn test_config_builder() {
426        let config = Config::mainnet()
427            .with_timeout(Duration::from_secs(60))
428            .with_user_agent("test-agent")
429            .with_retry(false);
430
431        assert_eq!(config.timeout, Duration::from_secs(60));
432        assert_eq!(config.user_agent, "test-agent");
433        assert!(!config.retry_enabled);
434    }
435
436    #[test]
437    fn test_query_params() {
438        let params = QueryParams::default().limit(10).offset(20).epoch(600);
439
440        let query = params.to_query_string();
441        assert!(query.contains("limit=10"));
442        assert!(query.contains("offset=20"));
443        assert!(query.contains("epoch=600"));
444    }
445
446    #[test]
447    fn test_client_builder() {
448        let client = KobeApiClientBuilder::new()
449            .timeout(Duration::from_secs(45))
450            .retry(true)
451            .max_retries(5)
452            .build();
453
454        assert_eq!(client.config.timeout, Duration::from_secs(45));
455        assert!(client.config.retry_enabled);
456        assert_eq!(client.config.max_retries, 5);
457    }
458}