Skip to main content

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