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 base_url(&self) -> &str {
34 &self.config.base_url
35 }
36
37 async fn get<T: DeserializeOwned>(
39 &self,
40 endpoint: &str,
41 query: &str,
42 ) -> Result<T, KobeApiError> {
43 let url = format!(
44 "{}/api/{}{}{}",
45 self.config.base_url,
46 crate::API_VERSION,
47 endpoint,
48 query
49 );
50 self.request(Method::GET, &url, None::<&()>).await
51 }
52
53 async fn post<B: Serialize, T: DeserializeOwned>(
55 &self,
56 endpoint: &str,
57 body: Option<&B>,
58 ) -> Result<T, KobeApiError> {
59 let url = format!(
60 "{}/api/{}{}",
61 self.config.base_url,
62 crate::API_VERSION,
63 endpoint
64 );
65 self.request(Method::POST, &url, body).await
66 }
67
68 async fn request<B: Serialize, T: DeserializeOwned>(
70 &self,
71 method: Method,
72 url: &str,
73 body: Option<&B>,
74 ) -> Result<T, KobeApiError> {
75 let mut retries = 0;
76 let max_retries = if self.config.retry_enabled {
77 self.config.max_retries
78 } else {
79 0
80 };
81
82 loop {
83 let mut request = self.client.request(method.clone(), url);
84
85 if let Some(body) = body {
86 request = request.json(body);
87 }
88
89 let response = request.send().await?;
90
91 match self.handle_response(response).await {
92 Ok(data) => return Ok(data),
93 Err(e) => {
94 if retries >= max_retries || !self.should_retry(&e) {
95 return Err(e);
96 }
97 retries += 1;
98 let delay = Duration::from_millis(100 * 2u64.pow(retries));
100 tokio::time::sleep(delay).await;
101 }
102 }
103 }
104 }
105
106 async fn handle_response<T: DeserializeOwned>(
108 &self,
109 response: Response,
110 ) -> Result<T, KobeApiError> {
111 let status = response.status();
112
113 if status.is_success() {
114 response.json::<T>().await.map_err(Into::into)
115 } else {
116 let status_code = status.as_u16();
117 let error_text = response
118 .text()
119 .await
120 .unwrap_or_else(|_| "Unknown error".to_string());
121
122 match status {
123 StatusCode::NOT_FOUND => Err(KobeApiError::NotFound(error_text)),
124 StatusCode::TOO_MANY_REQUESTS => Err(KobeApiError::RateLimitExceeded),
125 StatusCode::REQUEST_TIMEOUT => Err(KobeApiError::Timeout),
126 _ => Err(KobeApiError::api_error(status_code, error_text)),
127 }
128 }
129 }
130
131 fn should_retry(&self, error: &KobeApiError) -> bool {
133 matches!(error, KobeApiError::Timeout | KobeApiError::HttpError(_))
134 }
135
136 pub async fn get_staker_rewards(
156 &self,
157 limit: Option<u32>,
158 ) -> Result<StakerRewardsResponse, KobeApiError> {
159 let query = if let Some(limit) = limit {
160 format!("?limit={}", limit)
161 } else {
162 String::new()
163 };
164 self.get("/staker_rewards", &query).await
165 }
166
167 pub async fn get_staker_rewards_with_params(
169 &self,
170 params: &QueryParams,
171 ) -> Result<StakerRewardsResponse, KobeApiError> {
172 self.get("/staker_rewards", ¶ms.to_query_string()).await
173 }
174
175 pub async fn get_validator_rewards(
184 &self,
185 epoch: Option<u64>,
186 limit: Option<u32>,
187 ) -> Result<ValidatorRewardsResponse, KobeApiError> {
188 let mut params = Vec::new();
189
190 if let Some(epoch) = epoch {
191 params.push(format!("epoch={}", epoch));
192 }
193 if let Some(limit) = limit {
194 params.push(format!("limit={}", limit));
195 }
196
197 let query = if params.is_empty() {
198 String::new()
199 } else {
200 format!("?{}", params.join("&"))
201 };
202
203 self.get("/validator_rewards", &query).await
204 }
205
206 pub async fn get_validators(
214 &self,
215 epoch: Option<u64>,
216 ) -> Result<ValidatorsResponse, KobeApiError> {
217 if let Some(epoch) = epoch {
218 self.post("/validators", Some(&EpochRequest { epoch }))
219 .await
220 } else {
221 self.post::<EpochRequest, _>("/validators", None).await
222 }
223 }
224
225 pub async fn get_jitosol_validators(
229 &self,
230 epoch: Option<u64>,
231 ) -> Result<ValidatorsResponse, KobeApiError> {
232 if let Some(epoch) = epoch {
233 self.post("/jitosol_validators", Some(&EpochRequest { epoch }))
234 .await
235 } else {
236 self.post::<EpochRequest, _>("/jitosol_validators", None)
237 .await
238 }
239 }
240
241 pub async fn get_validator_history(
249 &self,
250 vote_account: &str,
251 ) -> Result<Vec<ValidatorHistory>, KobeApiError> {
252 self.get(&format!("/validators/{}", vote_account), "").await
253 }
254
255 pub async fn get_mev_rewards(&self, epoch: Option<u64>) -> Result<MevRewards, KobeApiError> {
263 if let Some(epoch) = epoch {
264 self.post("/mev_rewards", Some(&EpochRequest { epoch }))
265 .await
266 } else {
267 self.get("/mev_rewards", "").await
269 }
270 }
271
272 pub async fn get_daily_mev_tips(&self) -> Result<Vec<DailyMevTips>, KobeApiError> {
276 self.get("/daily_mev_tips", "").await
277 }
278
279 pub async fn get_jito_stake_over_time(&self) -> Result<JitoStakeOverTime, KobeApiError> {
283 self.get("/jito_stake_over_time", "").await
284 }
285
286 pub async fn get_mev_commission_average_over_time(
290 &self,
291 ) -> Result<MevCommissionAverageOverTime, KobeApiError> {
292 self.get("/mev_commission_average_over_time", "").await
293 }
294
295 pub async fn get_jitosol_sol_ratio(
302 &self,
303 start: chrono::DateTime<chrono::Utc>,
304 end: chrono::DateTime<chrono::Utc>,
305 ) -> Result<JitoSolRatio, KobeApiError> {
306 let request = RangeRequest {
307 range_filter: RangeFilter { start, end },
308 };
309 self.post("/jitosol_sol_ratio", Some(&request)).await
310 }
311
312 pub async fn get_stake_pool_stats(
318 &self,
319 epoch: Option<u64>,
320 ) -> Result<StakePoolStats, KobeApiError> {
321 if let Some(epoch) = epoch {
322 self.post("/stake_pool_stats", Some(&EpochRequest { epoch }))
323 .await
324 } else {
325 self.get("/stake_pool_stats", "").await
326 }
327 }
328
329 pub async fn get_current_epoch(&self) -> Result<u64, KobeApiError> {
331 let mev_rewards = self.get_mev_rewards(None).await?;
332 Ok(mev_rewards.epoch)
333 }
334
335 pub async fn get_jito_validators(&self) -> Result<Vec<ValidatorInfo>, KobeApiError> {
337 let response: ValidatorsResponse = self.get("/validators", "").await?;
338 Ok(response
339 .validators
340 .into_iter()
341 .filter(|v| v.running_jito)
342 .collect())
343 }
344
345 pub async fn get_validators_by_stake(
360 &self,
361 epoch: Option<u64>,
362 limit: usize,
363 ) -> Result<Vec<ValidatorInfo>, KobeApiError> {
364 let mut response = self.get_validators(epoch).await?;
365 response
366 .validators
367 .sort_by(|a, b| b.active_stake.cmp(&a.active_stake));
368 Ok(response.validators.into_iter().take(limit).collect())
369 }
370
371 pub async fn is_validator_running_jito(
373 &self,
374 vote_account: &str,
375 ) -> Result<bool, KobeApiError> {
376 let response = self.get_validators(None).await?;
377 Ok(response
378 .validators
379 .iter()
380 .find(|v| v.vote_account == vote_account)
381 .map(|v| v.running_jito)
382 .unwrap_or(false))
383 }
384
385 pub async fn calculate_total_mev_rewards(
400 &self,
401 start_epoch: u64,
402 end_epoch: u64,
403 ) -> Result<u64, KobeApiError> {
404 let mut total = 0u64;
405
406 for epoch in start_epoch..=end_epoch {
407 if let Ok(mev_rewards) = self.get_mev_rewards(Some(epoch)).await {
408 total = total.saturating_add(mev_rewards.total_network_mev_lamports);
409 }
410 }
411
412 Ok(total)
413 }
414}
415
416#[cfg(test)]
417mod tests {
418 use std::time::Duration;
419
420 use crate::{client_builder::KobeApiClientBuilder, types::QueryParams};
421
422 use super::Config;
423
424 #[test]
425 fn test_config_builder() {
426 let config = Config::mainnet()
427 .with_timeout(Duration::from_secs(60))
428 .with_user_agent("test-agent")
429 .with_retry(false);
430
431 assert_eq!(config.timeout, Duration::from_secs(60));
432 assert_eq!(config.user_agent, "test-agent");
433 assert!(!config.retry_enabled);
434 }
435
436 #[test]
437 fn test_query_params() {
438 let params = QueryParams::default().limit(10).offset(20).epoch(600);
439
440 let query = params.to_query_string();
441 assert!(query.contains("limit=10"));
442 assert!(query.contains("offset=20"));
443 assert!(query.contains("epoch=600"));
444 }
445
446 #[test]
447 fn test_client_builder() {
448 let client = KobeApiClientBuilder::new()
449 .timeout(Duration::from_secs(45))
450 .retry(true)
451 .max_retries(5)
452 .build();
453
454 assert_eq!(client.config.timeout, Duration::from_secs(45));
455 assert!(client.config.retry_enabled);
456 assert_eq!(client.config.max_retries, 5);
457 }
458}