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#[derive(Debug, Clone)]
17pub struct KobeClient {
18 client: Client,
19 config: Config,
20}
21
22impl KobeClient {
23 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 pub fn mainnet() -> Self {
36 Self::new(Config::mainnet())
37 }
38
39 pub fn testnet() -> Self {
41 Self::new(Config::testnet())
42 }
43
44 pub fn base_url(&self) -> &str {
46 &self.config.base_url
47 }
48
49 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 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 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 let delay = Duration::from_millis(100 * 2u64.pow(retries));
112 tokio::time::sleep(delay).await;
113 }
114 }
115 }
116 }
117
118 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 fn should_retry(&self, error: &KobeApiError) -> bool {
145 matches!(error, KobeApiError::Timeout | KobeApiError::HttpError(_))
146 }
147
148 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 pub async fn get_staker_rewards_with_params(
181 &self,
182 params: &QueryParams,
183 ) -> Result<StakerRewardsResponse, KobeApiError> {
184 self.get("/staker_rewards", ¶ms.to_query_string()).await
185 }
186
187 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 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 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 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 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 self.get("/mev_rewards", "").await
281 }
282 }
283
284 pub async fn get_daily_mev_rewards(&self) -> Result<Vec<DailyMevRewards>, KobeApiError> {
288 self.get("/daily_mev_rewards", "").await
289 }
290
291 pub async fn get_jito_stake_over_time(&self) -> Result<JitoStakeOverTime, KobeApiError> {
295 self.get("/jito_stake_over_time", "").await
296 }
297
298 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 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 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 self.get("/stake_pool_stats", "").await
337 }
338 }
339
340 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 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 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 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 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 pub async fn get_bam_delegation_blacklist(
430 &self,
431 ) -> Result<Vec<BamDelegationBlacklistEntry>, KobeApiError> {
432 self.get("/bam_delegation_blacklist", "").await
433 }
434
435 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 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 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}