kobe_client/
client.rs

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