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#[derive(Debug, Clone)]
10pub struct KobeClient {
11 client: Client,
12 config: Config,
13}
14
15impl KobeClient {
16 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 pub fn mainnet() -> Self {
29 Self::new(Config::mainnet())
30 }
31
32 pub fn testnet() -> Self {
34 Self::new(Config::testnet())
35 }
36
37 pub fn base_url(&self) -> &str {
39 &self.config.base_url
40 }
41
42 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 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 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 let delay = Duration::from_millis(100 * 2u64.pow(retries));
105 tokio::time::sleep(delay).await;
106 }
107 }
108 }
109 }
110
111 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 fn should_retry(&self, error: &KobeApiError) -> bool {
138 matches!(error, KobeApiError::Timeout | KobeApiError::HttpError(_))
139 }
140
141 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 pub async fn get_staker_rewards_with_params(
174 &self,
175 params: &QueryParams,
176 ) -> Result<StakerRewardsResponse, KobeApiError> {
177 self.get("/staker_rewards", ¶ms.to_query_string()).await
178 }
179
180 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 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 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 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 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 self.get("/mev_rewards", "").await
274 }
275 }
276
277 pub async fn get_daily_mev_rewards(&self) -> Result<Vec<DailyMevRewards>, KobeApiError> {
281 self.get("/daily_mev_rewards", "").await
282 }
283
284 pub async fn get_jito_stake_over_time(&self) -> Result<JitoStakeOverTime, KobeApiError> {
288 self.get("/jito_stake_over_time", "").await
289 }
290
291 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 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 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 self.get("/stake_pool_stats", "").await
330 }
331 }
332
333 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 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 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 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 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 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}