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::{bam_epoch_metrics::BamEpochMetricsResponse, bam_validators::BamValidatorsResponse, *},
10};
11
12#[derive(Debug, Clone)]
14pub struct KobeClient {
15 client: Client,
16 config: Config,
17}
18
19impl KobeClient {
20 pub fn new(config: Config) -> Self {
22 let client = Client::builder()
23 .timeout(config.timeout)
24 .user_agent(&config.user_agent)
25 .build()
26 .expect("Failed to build HTTP client");
27
28 Self { client, config }
29 }
30
31 pub fn mainnet() -> Self {
33 Self::new(Config::mainnet())
34 }
35
36 pub fn testnet() -> Self {
38 Self::new(Config::testnet())
39 }
40
41 pub fn base_url(&self) -> &str {
43 &self.config.base_url
44 }
45
46 async fn get<T: DeserializeOwned>(
48 &self,
49 endpoint: &str,
50 query: &str,
51 ) -> Result<T, KobeApiError> {
52 let url = format!(
53 "{}/api/{}{}{}",
54 self.config.base_url,
55 crate::API_VERSION,
56 endpoint,
57 query
58 );
59 self.request(Method::GET, &url, None::<&()>).await
60 }
61
62 async fn post<B: Serialize, T: DeserializeOwned>(
64 &self,
65 endpoint: &str,
66 body: Option<&B>,
67 ) -> Result<T, KobeApiError> {
68 let url = format!(
69 "{}/api/{}{}",
70 self.config.base_url,
71 crate::API_VERSION,
72 endpoint
73 );
74 self.request(Method::POST, &url, body).await
75 }
76
77 async fn request<B: Serialize, T: DeserializeOwned>(
79 &self,
80 method: Method,
81 url: &str,
82 body: Option<&B>,
83 ) -> Result<T, KobeApiError> {
84 let mut retries = 0;
85 let max_retries = if self.config.retry_enabled {
86 self.config.max_retries
87 } else {
88 0
89 };
90
91 loop {
92 let mut request = self.client.request(method.clone(), url);
93
94 if let Some(body) = body {
95 request = request.json(body);
96 }
97
98 let response = request.send().await?;
99
100 match self.handle_response(response).await {
101 Ok(data) => return Ok(data),
102 Err(e) => {
103 if retries >= max_retries || !self.should_retry(&e) {
104 return Err(e);
105 }
106 retries += 1;
107 let delay = Duration::from_millis(100 * 2u64.pow(retries));
109 tokio::time::sleep(delay).await;
110 }
111 }
112 }
113 }
114
115 async fn handle_response<T: DeserializeOwned>(
117 &self,
118 response: Response,
119 ) -> Result<T, KobeApiError> {
120 let status = response.status();
121
122 if status.is_success() {
123 response.json::<T>().await.map_err(Into::into)
124 } else {
125 let status_code = status.as_u16();
126 let error_text = response
127 .text()
128 .await
129 .unwrap_or_else(|_| "Unknown error".to_string());
130
131 match status {
132 StatusCode::NOT_FOUND => Err(KobeApiError::NotFound(error_text)),
133 StatusCode::TOO_MANY_REQUESTS => Err(KobeApiError::RateLimitExceeded),
134 StatusCode::REQUEST_TIMEOUT => Err(KobeApiError::Timeout),
135 _ => Err(KobeApiError::api_error(status_code, error_text)),
136 }
137 }
138 }
139
140 fn should_retry(&self, error: &KobeApiError) -> bool {
142 matches!(error, KobeApiError::Timeout | KobeApiError::HttpError(_))
143 }
144
145 pub async fn get_staker_rewards(
165 &self,
166 limit: Option<u32>,
167 ) -> Result<StakerRewardsResponse, KobeApiError> {
168 let query = if let Some(limit) = limit {
169 format!("?limit={}", limit)
170 } else {
171 String::new()
172 };
173 self.get("/staker_rewards", &query).await
174 }
175
176 pub async fn get_staker_rewards_with_params(
178 &self,
179 params: &QueryParams,
180 ) -> Result<StakerRewardsResponse, KobeApiError> {
181 self.get("/staker_rewards", ¶ms.to_query_string()).await
182 }
183
184 pub async fn get_validator_rewards(
193 &self,
194 epoch: Option<u64>,
195 limit: Option<u32>,
196 ) -> Result<ValidatorRewardsResponse, KobeApiError> {
197 let mut params = Vec::new();
198
199 if let Some(epoch) = epoch {
200 params.push(format!("epoch={}", epoch));
201 }
202 if let Some(limit) = limit {
203 params.push(format!("limit={}", limit));
204 }
205
206 let query = if params.is_empty() {
207 String::new()
208 } else {
209 format!("?{}", params.join("&"))
210 };
211
212 self.get("/validator_rewards", &query).await
213 }
214
215 pub async fn get_validators(
223 &self,
224 epoch: Option<u64>,
225 ) -> Result<ValidatorsResponse, KobeApiError> {
226 if let Some(epoch) = epoch {
227 self.post("/validators", Some(&EpochRequest { epoch }))
228 .await
229 } else {
230 self.post::<EpochRequest, _>("/validators", None).await
231 }
232 }
233
234 pub async fn get_jitosol_validators(
238 &self,
239 epoch: Option<u64>,
240 ) -> Result<ValidatorsResponse, KobeApiError> {
241 if let Some(epoch) = epoch {
242 self.post("/jitosol_validators", Some(&EpochRequest { epoch }))
243 .await
244 } else {
245 self.post::<EpochRequest, _>("/jitosol_validators", None)
246 .await
247 }
248 }
249
250 pub async fn get_validator_info_by_vote_account(
258 &self,
259 vote_account: &str,
260 ) -> Result<Vec<ValidatorByVoteAccount>, KobeApiError> {
261 self.get(&format!("/validators/{}", vote_account), "").await
262 }
263
264 pub async fn get_mev_rewards(&self, epoch: Option<u64>) -> Result<MevRewards, KobeApiError> {
272 if let Some(epoch) = epoch {
273 self.post("/mev_rewards", Some(&EpochRequest { epoch }))
274 .await
275 } else {
276 self.get("/mev_rewards", "").await
278 }
279 }
280
281 pub async fn get_daily_mev_rewards(&self) -> Result<Vec<DailyMevRewards>, KobeApiError> {
285 self.get("/daily_mev_rewards", "").await
286 }
287
288 pub async fn get_jito_stake_over_time(&self) -> Result<JitoStakeOverTime, KobeApiError> {
292 self.get("/jito_stake_over_time", "").await
293 }
294
295 pub async fn get_mev_commission_average_over_time(
299 &self,
300 ) -> Result<MevCommissionAverageOverTime, KobeApiError> {
301 self.get("/mev_commission_average_over_time", "").await
302 }
303
304 pub async fn get_jitosol_sol_ratio(
311 &self,
312 start: chrono::DateTime<chrono::Utc>,
313 end: chrono::DateTime<chrono::Utc>,
314 ) -> Result<JitoSolRatio, KobeApiError> {
315 let request = RangeRequest {
316 range_filter: RangeFilter { start, end },
317 };
318 self.post("/jitosol_sol_ratio", Some(&request)).await
319 }
320
321 pub async fn get_stake_pool_stats(
326 &self,
327 request: Option<&StakePoolStatsRequest>,
328 ) -> Result<StakePoolStats, KobeApiError> {
329 if let Some(req) = request {
330 self.post("/stake_pool_stats", Some(req)).await
331 } else {
332 self.get("/stake_pool_stats", "").await
334 }
335 }
336
337 pub async fn get_current_epoch(&self) -> Result<u64, KobeApiError> {
339 let mev_rewards = self.get_mev_rewards(None).await?;
340 Ok(mev_rewards.epoch)
341 }
342
343 pub async fn get_jito_validators(&self) -> Result<Vec<ValidatorInfo>, KobeApiError> {
345 let response: ValidatorsResponse = self.get("/validators", "").await?;
346 Ok(response
347 .validators
348 .into_iter()
349 .filter(|v| v.running_jito)
350 .collect())
351 }
352
353 pub async fn get_validators_by_stake(
368 &self,
369 epoch: Option<u64>,
370 limit: usize,
371 ) -> Result<Vec<ValidatorInfo>, KobeApiError> {
372 let mut response = self.get_validators(epoch).await?;
373 response
374 .validators
375 .sort_by(|a, b| b.active_stake.cmp(&a.active_stake));
376 Ok(response.validators.into_iter().take(limit).collect())
377 }
378
379 pub async fn is_validator_running_jito(
381 &self,
382 vote_account: &str,
383 ) -> Result<bool, KobeApiError> {
384 let response = self.get_validators(None).await?;
385 Ok(response
386 .validators
387 .iter()
388 .find(|v| v.vote_account == vote_account)
389 .map(|v| v.running_jito)
390 .unwrap_or(false))
391 }
392
393 pub async fn calculate_total_mev_rewards(
408 &self,
409 start_epoch: u64,
410 end_epoch: u64,
411 ) -> Result<u64, KobeApiError> {
412 let mut total = 0u64;
413
414 for epoch in start_epoch..=end_epoch {
415 if let Ok(mev_rewards) = self.get_mev_rewards(Some(epoch)).await {
416 total = total.saturating_add(mev_rewards.total_network_mev_lamports);
417 }
418 }
419
420 Ok(total)
421 }
422
423 pub async fn get_bam_delegation_blacklist(
427 &self,
428 ) -> Result<Vec<BamDelegationBlacklistEntry>, KobeApiError> {
429 self.get("/bam_delegation_blacklist", "").await
430 }
431
432 pub async fn get_bam_epoch_metrics(
436 &self,
437 epoch: u64,
438 ) -> Result<BamEpochMetricsResponse, KobeApiError> {
439 let query = format!("?epoch={epoch}");
440 self.get("/bam_epoch_metrics", &query).await
441 }
442
443 pub async fn get_bam_validators(
447 &self,
448 epoch: u64,
449 ) -> Result<BamValidatorsResponse, KobeApiError> {
450 let query = format!("?epoch={epoch}");
451 self.get("/bam_validators", &query).await
452 }
453}
454
455#[cfg(test)]
456mod tests {
457 use std::time::Duration;
458
459 use crate::{client_builder::KobeApiClientBuilder, types::QueryParams};
460
461 use super::Config;
462
463 #[test]
464 fn test_config_builder() {
465 let config = Config::mainnet()
466 .with_timeout(Duration::from_secs(60))
467 .with_user_agent("test-agent")
468 .with_retry(false);
469
470 assert_eq!(config.timeout, Duration::from_secs(60));
471 assert_eq!(config.user_agent, "test-agent");
472 assert!(!config.retry_enabled);
473 }
474
475 #[test]
476 fn test_query_params() {
477 let params = QueryParams::default().limit(10).offset(20).epoch(600);
478
479 let query = params.to_query_string();
480 assert!(query.contains("limit=10"));
481 assert!(query.contains("offset=20"));
482 assert!(query.contains("epoch=600"));
483 }
484
485 #[test]
486 fn test_client_builder() {
487 let client = KobeApiClientBuilder::new()
488 .timeout(Duration::from_secs(45))
489 .retry(true)
490 .max_retries(5)
491 .build();
492
493 assert_eq!(client.config.timeout, Duration::from_secs(45));
494 assert!(client.config.retry_enabled);
495 assert_eq!(client.config.max_retries, 5);
496 }
497}