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