kora_lib/oracle/
jupiter.rs

1use super::{PriceOracle, PriceSource, TokenPrice};
2use crate::{
3    constant::{JUPITER_API_LITE_URL, JUPITER_API_PRO_URL, SOL_MINT},
4    error::KoraError,
5    sanitize_error,
6    validator::math_validator,
7};
8use once_cell::sync::Lazy;
9use parking_lot::RwLock;
10use reqwest::{Client, StatusCode};
11use rust_decimal::Decimal;
12use serde::Deserialize;
13use std::{collections::HashMap, sync::Arc};
14
15const JUPITER_AUTH_HEADER: &str = "x-api-key";
16
17const JUPITER_DEFAULT_CONFIDENCE: f64 = 0.95;
18
19const MAX_REASONABLE_PRICE: f64 = 1_000_000.0;
20const MIN_REASONABLE_PRICE: f64 = 0.000_000_001;
21
22static GLOBAL_JUPITER_API_KEY: Lazy<Arc<RwLock<Option<String>>>> =
23    Lazy::new(|| Arc::new(RwLock::new(None)));
24
25/// Initialize the global Jupiter API key from the environment variable
26pub fn init_jupiter_api_key() {
27    let mut api_key_guard = GLOBAL_JUPITER_API_KEY.write();
28    if api_key_guard.is_none() {
29        *api_key_guard = std::env::var("JUPITER_API_KEY").ok();
30    }
31}
32
33/// Get the global Jupiter API key
34fn get_jupiter_api_key() -> Option<String> {
35    let api_key_guard = GLOBAL_JUPITER_API_KEY.read();
36    api_key_guard.clone()
37}
38
39type JupiterResponse = HashMap<String, JupiterPriceData>;
40
41#[derive(Debug, Deserialize)]
42struct JupiterPriceData {
43    #[serde(rename = "usdPrice")]
44    usd_price: f64,
45    #[serde(rename = "blockId")]
46    #[allow(dead_code)]
47    block_id: u64,
48    #[allow(dead_code)]
49    decimals: u8,
50    #[serde(rename = "priceChange24h")]
51    #[allow(dead_code)]
52    price_change_24h: Option<f64>,
53}
54
55pub struct JupiterPriceOracle {
56    pro_api_url: String,
57    lite_api_url: String,
58    api_key: Option<String>,
59}
60
61impl Default for JupiterPriceOracle {
62    fn default() -> Self {
63        Self::new()
64    }
65}
66
67impl JupiterPriceOracle {
68    pub fn new() -> Self {
69        // Use provided API key, or fallback to global API key from environment
70        let api_key = get_jupiter_api_key();
71
72        let pro_api_url = Self::build_price_api_url(JUPITER_API_PRO_URL);
73        let lite_api_url = Self::build_price_api_url(JUPITER_API_LITE_URL);
74
75        Self { pro_api_url, lite_api_url, api_key }
76    }
77
78    fn build_price_api_url(base_url: &str) -> String {
79        let trimmed = base_url.trim_end_matches('/');
80        format!("{trimmed}/price/v3")
81    }
82}
83
84#[async_trait::async_trait]
85impl PriceOracle for JupiterPriceOracle {
86    async fn get_price(
87        &self,
88        client: &Client,
89        mint_address: &str,
90    ) -> Result<TokenPrice, KoraError> {
91        let prices = self.get_prices(client, &[mint_address.to_string()]).await?;
92
93        prices.get(mint_address).cloned().ok_or_else(|| {
94            KoraError::RpcError(format!("No price data from Jupiter for mint {mint_address}"))
95        })
96    }
97
98    async fn get_prices(
99        &self,
100        client: &Client,
101        mint_addresses: &[String],
102    ) -> Result<HashMap<String, TokenPrice>, KoraError> {
103        if mint_addresses.is_empty() {
104            return Ok(HashMap::new());
105        }
106
107        // Try pro API first if API key is available, then fallback to free API
108        if let Some(api_key) = &self.api_key {
109            match self
110                .fetch_prices_from_url(client, &self.pro_api_url, mint_addresses, Some(api_key))
111                .await
112            {
113                Ok(prices) => return Ok(prices),
114                Err(e) => {
115                    if e == KoraError::RateLimitExceeded {
116                        log::warn!("Pro Jupiter API rate limit exceeded, falling back to free API");
117                    } else {
118                        return Err(e);
119                    }
120                }
121            }
122        }
123
124        // Use free API (either as fallback or primary if no API key)
125        self.fetch_prices_from_url(client, &self.lite_api_url, mint_addresses, None).await
126    }
127}
128
129impl JupiterPriceOracle {
130    fn validate_price_data(price_data: &JupiterPriceData, mint: &str) -> Result<(), KoraError> {
131        let price = price_data.usd_price;
132
133        math_validator::validate_division(price)?;
134
135        // Sanity check: price should be within reasonable bounds
136        if price > MAX_REASONABLE_PRICE {
137            log::error!(
138                "Price data for mint {} exceeds reasonable bounds: {} > {}",
139                mint,
140                price,
141                MAX_REASONABLE_PRICE
142            );
143            return Err(KoraError::RpcError(format!(
144                "Price data for mint {} exceeds reasonable bounds",
145                mint
146            )));
147        }
148
149        if price < MIN_REASONABLE_PRICE {
150            log::error!(
151                "Price data for mint {} below reasonable bounds: {} < {}",
152                mint,
153                price,
154                MIN_REASONABLE_PRICE
155            );
156            return Err(KoraError::RpcError(format!(
157                "Price data for mint {} below reasonable bounds",
158                mint
159            )));
160        }
161
162        Ok(())
163    }
164
165    async fn fetch_prices_from_url(
166        &self,
167        client: &Client,
168        api_url: &str,
169        mint_addresses: &[String],
170        api_key: Option<&String>,
171    ) -> Result<HashMap<String, TokenPrice>, KoraError> {
172        if mint_addresses.is_empty() {
173            return Ok(HashMap::new());
174        }
175
176        let mut all_mints = vec![SOL_MINT.to_string()];
177        all_mints.extend_from_slice(mint_addresses);
178        let ids = all_mints.join(",");
179
180        let url = format!("{api_url}?ids={ids}");
181
182        let mut request = client.get(&url);
183
184        // Add API key header if provided
185        if let Some(key) = api_key {
186            request = request.header(JUPITER_AUTH_HEADER, key);
187        }
188
189        let response = request.send().await.map_err(|e| {
190            KoraError::RpcError(format!("Jupiter API request failed: {}", sanitize_error!(e)))
191        })?;
192
193        if !response.status().is_success() {
194            match response.status() {
195                StatusCode::TOO_MANY_REQUESTS => {
196                    return Err(KoraError::RateLimitExceeded);
197                }
198                _ => {
199                    return Err(KoraError::RpcError(format!(
200                        "Jupiter API error: {}",
201                        response.status()
202                    )));
203                }
204            }
205        }
206
207        let jupiter_response: JupiterResponse = response.json().await.map_err(|e| {
208            KoraError::RpcError(format!("Failed to parse Jupiter response: {}", sanitize_error!(e)))
209        })?;
210
211        // Get SOL price for conversion
212        let sol_price = jupiter_response
213            .get(SOL_MINT)
214            .ok_or_else(|| KoraError::RpcError("No SOL price data from Jupiter".to_string()))?;
215
216        Self::validate_price_data(sol_price, SOL_MINT)?;
217
218        // Convert all prices to SOL-denominated
219        let mut result = HashMap::new();
220        for mint_address in mint_addresses {
221            if let Some(price_data) = jupiter_response.get(mint_address.as_str()) {
222                Self::validate_price_data(price_data, mint_address)?;
223
224                // Convert f64 USD prices to Decimal at API boundary
225                let token_usd =
226                    Decimal::from_f64_retain(price_data.usd_price).ok_or_else(|| {
227                        KoraError::RpcError(format!("Invalid token price for mint {mint_address}"))
228                    })?;
229                let sol_usd = Decimal::from_f64_retain(sol_price.usd_price).ok_or_else(|| {
230                    KoraError::RpcError("Invalid SOL price from Jupiter".to_string())
231                })?;
232
233                let price_in_sol = token_usd / sol_usd;
234
235                result.insert(
236                    mint_address.clone(),
237                    TokenPrice {
238                        price: price_in_sol,
239                        confidence: JUPITER_DEFAULT_CONFIDENCE,
240                        source: PriceSource::Jupiter,
241                    },
242                );
243            } else {
244                log::error!("No price data for mint {mint_address} from Jupiter");
245                return Err(KoraError::RpcError(format!(
246                    "No price data from Jupiter for mint {mint_address}"
247                )));
248            }
249        }
250
251        if result.is_empty() {
252            return Err(KoraError::RpcError("No price data from Jupiter".to_string()));
253        }
254
255        Ok(result)
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use mockito::{Matcher, Server};
263
264    #[tokio::test]
265    async fn test_jupiter_price_fetch_comprehensive() {
266        // Test case 1: No API key - should use lite API
267        {
268            let mut api_key_guard = GLOBAL_JUPITER_API_KEY.write();
269
270            *api_key_guard = None;
271        }
272
273        let mock_response = r#"{
274            "So11111111111111111111111111111111111111112": {
275                "usdPrice": 100.0,
276                "blockId": 12345,
277                "decimals": 9,
278                "priceChange24h": 2.5
279            },
280            "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN": {
281                "usdPrice": 0.532,
282                "blockId": 12345,
283                "decimals": 6,
284                "priceChange24h": -1.2
285            }
286        }"#;
287        let mut server = Server::new_async().await;
288        let _m1 = server
289            .mock("GET", "/price/v3")
290            .match_query(Matcher::Any)
291            .with_status(200)
292            .with_header("content-type", "application/json")
293            .with_body(mock_response)
294            .create();
295
296        let client = Client::new();
297        let mut oracle = JupiterPriceOracle::new();
298        oracle.lite_api_url = format!("{}/price/v3", server.url());
299
300        let result = oracle.get_price(&client, "So11111111111111111111111111111111111111112").await;
301        assert!(result.is_ok());
302        let price = result.unwrap();
303        assert_eq!(price.price, Decimal::from(1));
304        assert_eq!(price.source, PriceSource::Jupiter);
305
306        // Test case 2: With API key - should use pro API
307        {
308            let mut api_key_guard = GLOBAL_JUPITER_API_KEY.write();
309            *api_key_guard = Some("test-api-key".to_string());
310        }
311
312        let mut server2 = Server::new_async().await;
313        let _m2 = server2
314            .mock("GET", "/price/v3")
315            .match_header("x-api-key", "test-api-key")
316            .match_query(Matcher::Any)
317            .with_status(200)
318            .with_header("content-type", "application/json")
319            .with_body(mock_response)
320            .create();
321
322        let mut oracle2 = JupiterPriceOracle::new();
323        oracle2.pro_api_url = format!("{}/price/v3", server2.url());
324
325        let result =
326            oracle2.get_price(&client, "So11111111111111111111111111111111111111112").await;
327        assert!(result.is_ok());
328        let price = result.unwrap();
329        assert_eq!(price.price, Decimal::from(1));
330        assert_eq!(price.source, PriceSource::Jupiter);
331
332        // Test case 3: No price data available - should return error
333        {
334            let mut api_key_guard = GLOBAL_JUPITER_API_KEY.write();
335            *api_key_guard = None;
336        }
337
338        let no_price_response = r#"{
339            "So11111111111111111111111111111111111111112": {
340                "usdPrice": 100.0,
341                "blockId": 12345,
342                "decimals": 9,
343                "priceChange24h": 2.5
344            }
345        }"#;
346        let mut server3 = Server::new_async().await;
347        let _m3 = server3
348            .mock("GET", "/price/v3")
349            .match_query(Matcher::Any)
350            .with_status(200)
351            .with_header("content-type", "application/json")
352            .with_body(no_price_response)
353            .create();
354
355        let mut oracle3 = JupiterPriceOracle::new();
356        oracle3.lite_api_url = format!("{}/price/v3", server3.url());
357
358        let result =
359            oracle3.get_price(&client, "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN").await;
360        assert!(result.is_err());
361        assert_eq!(
362            result.err(),
363            Some(KoraError::RpcError(
364                "No price data from Jupiter for mint JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN"
365                    .to_string()
366            ))
367        );
368    }
369}