Skip to main content

kora_lib/oracle/
jupiter.rs

1use super::{PriceOracle, PriceSource, TokenPrice};
2use crate::{
3    constant::{JUPITER_API_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/// Get the global Jupiter API key, falling back to environment variable
26fn get_jupiter_api_key() -> Option<String> {
27    let api_key_guard = GLOBAL_JUPITER_API_KEY.read();
28    api_key_guard.clone().or_else(|| std::env::var("JUPITER_API_KEY").ok())
29}
30
31type JupiterResponse = HashMap<String, JupiterPriceData>;
32
33#[derive(Debug, Deserialize)]
34struct JupiterPriceData {
35    #[serde(rename = "usdPrice")]
36    usd_price: f64,
37    #[serde(rename = "blockId")]
38    block_id: u64,
39    #[allow(dead_code)]
40    decimals: u8,
41    #[serde(rename = "priceChange24h")]
42    #[allow(dead_code)]
43    price_change_24h: Option<f64>,
44}
45
46pub struct JupiterPriceOracle {
47    api_url: String,
48    api_key: String,
49}
50
51impl JupiterPriceOracle {
52    pub fn new() -> Result<Self, KoraError> {
53        let api_key = get_jupiter_api_key().ok_or_else(|| {
54            log::error!("Jupiter API key not found. Set JUPITER_API_KEY environment variable.");
55            KoraError::ConfigError(
56                "Jupiter API key not found. Set JUPITER_API_KEY environment variable".to_string(),
57            )
58        })?;
59
60        let api_url = Self::build_price_api_url(JUPITER_API_URL);
61
62        Ok(Self { api_url, api_key })
63    }
64
65    fn build_price_api_url(base_url: &str) -> String {
66        let trimmed = base_url.trim_end_matches('/');
67        format!("{trimmed}/price/v3")
68    }
69}
70
71#[async_trait::async_trait]
72impl PriceOracle for JupiterPriceOracle {
73    async fn get_price(
74        &self,
75        client: &Client,
76        mint_address: &str,
77    ) -> Result<TokenPrice, KoraError> {
78        let prices = self.get_prices(client, &[mint_address.to_string()]).await?;
79
80        prices.get(mint_address).cloned().ok_or_else(|| {
81            KoraError::RpcError(format!("No price data from Jupiter for mint {mint_address}"))
82        })
83    }
84
85    async fn get_prices(
86        &self,
87        client: &Client,
88        mint_addresses: &[String],
89    ) -> Result<HashMap<String, TokenPrice>, KoraError> {
90        if mint_addresses.is_empty() {
91            return Ok(HashMap::new());
92        }
93
94        self.fetch_prices_from_url(client, &self.api_url, mint_addresses, &self.api_key).await
95    }
96}
97
98impl JupiterPriceOracle {
99    fn validate_price_data(price_data: &JupiterPriceData, mint: &str) -> Result<(), KoraError> {
100        let price = price_data.usd_price;
101
102        math_validator::validate_division(price)?;
103
104        // Sanity check: price should be within reasonable bounds
105        if price > MAX_REASONABLE_PRICE {
106            log::error!(
107                "Price data for mint {} exceeds reasonable bounds: {} > {}",
108                mint,
109                price,
110                MAX_REASONABLE_PRICE
111            );
112            return Err(KoraError::RpcError(format!(
113                "Price data for mint {} exceeds reasonable bounds",
114                mint
115            )));
116        }
117
118        if price < MIN_REASONABLE_PRICE {
119            log::error!(
120                "Price data for mint {} below reasonable bounds: {} < {}",
121                mint,
122                price,
123                MIN_REASONABLE_PRICE
124            );
125            return Err(KoraError::RpcError(format!(
126                "Price data for mint {} below reasonable bounds",
127                mint
128            )));
129        }
130
131        Ok(())
132    }
133
134    async fn fetch_prices_from_url(
135        &self,
136        client: &Client,
137        api_url: &str,
138        mint_addresses: &[String],
139        api_key: &str,
140    ) -> Result<HashMap<String, TokenPrice>, KoraError> {
141        if mint_addresses.is_empty() {
142            return Ok(HashMap::new());
143        }
144
145        let mut all_mints = vec![SOL_MINT.to_string()];
146        all_mints.extend_from_slice(mint_addresses);
147        let ids = all_mints.join(",");
148
149        let url = format!("{api_url}?ids={ids}");
150
151        let request = client.get(&url).header(JUPITER_AUTH_HEADER, api_key);
152
153        let response = request.send().await.map_err(|e| {
154            KoraError::RpcError(format!("Jupiter API request failed: {}", sanitize_error!(e)))
155        })?;
156
157        if !response.status().is_success() {
158            match response.status() {
159                StatusCode::TOO_MANY_REQUESTS => {
160                    return Err(KoraError::RateLimitExceeded);
161                }
162                _ => {
163                    return Err(KoraError::RpcError(format!(
164                        "Jupiter API error: {}",
165                        response.status()
166                    )));
167                }
168            }
169        }
170
171        let jupiter_response: JupiterResponse = response.json().await.map_err(|e| {
172            KoraError::RpcError(format!("Failed to parse Jupiter response: {}", sanitize_error!(e)))
173        })?;
174
175        // Get SOL price for conversion
176        let sol_price = jupiter_response
177            .get(SOL_MINT)
178            .ok_or_else(|| KoraError::RpcError("No SOL price data from Jupiter".to_string()))?;
179
180        Self::validate_price_data(sol_price, SOL_MINT)?;
181
182        // Convert all prices to SOL-denominated
183        let mut result = HashMap::new();
184        for mint_address in mint_addresses {
185            if let Some(price_data) = jupiter_response.get(mint_address.as_str()) {
186                Self::validate_price_data(price_data, mint_address)?;
187
188                // Convert f64 USD prices to Decimal at API boundary
189                let token_usd =
190                    Decimal::from_f64_retain(price_data.usd_price).ok_or_else(|| {
191                        KoraError::RpcError(format!("Invalid token price for mint {mint_address}"))
192                    })?;
193                let sol_usd = Decimal::from_f64_retain(sol_price.usd_price).ok_or_else(|| {
194                    KoraError::RpcError("Invalid SOL price from Jupiter".to_string())
195                })?;
196
197                let price_in_sol = token_usd / sol_usd;
198
199                result.insert(
200                    mint_address.clone(),
201                    TokenPrice {
202                        price: price_in_sol,
203                        confidence: JUPITER_DEFAULT_CONFIDENCE,
204                        source: PriceSource::Jupiter,
205                        block_id: Some(std::cmp::min(price_data.block_id, sol_price.block_id)),
206                    },
207                );
208            } else {
209                log::error!("No price data for mint {mint_address} from Jupiter");
210                return Err(KoraError::RpcError(format!(
211                    "No price data from Jupiter for mint {mint_address}"
212                )));
213            }
214        }
215
216        if result.is_empty() {
217            return Err(KoraError::RpcError("No price data from Jupiter".to_string()));
218        }
219
220        Ok(result)
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use mockito::{Matcher, Server};
228    use serial_test::serial;
229
230    #[test]
231    #[serial]
232    fn test_new_fails_without_api_key() {
233        // Clear both the global state AND the environment variable
234        std::env::remove_var("JUPITER_API_KEY");
235        {
236            let mut api_key_guard = GLOBAL_JUPITER_API_KEY.write();
237            *api_key_guard = None;
238        }
239
240        let result = JupiterPriceOracle::new();
241        assert!(result.is_err());
242        assert!(matches!(result.err(), Some(KoraError::ConfigError(_))));
243    }
244
245    #[tokio::test]
246    #[serial]
247    async fn test_jupiter_price_fetch_with_api_key() {
248        {
249            let mut api_key_guard = GLOBAL_JUPITER_API_KEY.write();
250            *api_key_guard = Some("test-api-key".to_string());
251        }
252
253        let mock_response = r#"{
254            "So11111111111111111111111111111111111111112": {
255                "usdPrice": 100.0,
256                "blockId": 12345,
257                "decimals": 9,
258                "priceChange24h": 2.5
259            },
260            "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN": {
261                "usdPrice": 0.532,
262                "blockId": 12345,
263                "decimals": 6,
264                "priceChange24h": -1.2
265            }
266        }"#;
267
268        let mut server = Server::new_async().await;
269        let _m = server
270            .mock("GET", "/price/v3")
271            .match_header("x-api-key", "test-api-key")
272            .match_query(Matcher::Any)
273            .with_status(200)
274            .with_header("content-type", "application/json")
275            .with_body(mock_response)
276            .create();
277
278        let client = Client::new();
279        let mut oracle = JupiterPriceOracle::new().unwrap();
280        oracle.api_url = format!("{}/price/v3", server.url());
281
282        let result = oracle.get_price(&client, "So11111111111111111111111111111111111111112").await;
283        assert!(result.is_ok());
284        let price = result.unwrap();
285        assert_eq!(price.price, Decimal::from(1));
286        assert_eq!(price.source, PriceSource::Jupiter);
287    }
288
289    #[tokio::test]
290    #[serial]
291    async fn test_jupiter_missing_price_data_returns_error() {
292        {
293            let mut api_key_guard = GLOBAL_JUPITER_API_KEY.write();
294            *api_key_guard = Some("test-api-key".to_string());
295        }
296
297        let no_price_response = r#"{
298            "So11111111111111111111111111111111111111112": {
299                "usdPrice": 100.0,
300                "blockId": 12345,
301                "decimals": 9,
302                "priceChange24h": 2.5
303            }
304        }"#;
305
306        let mut server = Server::new_async().await;
307        let _m = server
308            .mock("GET", "/price/v3")
309            .match_header("x-api-key", "test-api-key")
310            .match_query(Matcher::Any)
311            .with_status(200)
312            .with_header("content-type", "application/json")
313            .with_body(no_price_response)
314            .create();
315
316        let client = Client::new();
317        let mut oracle = JupiterPriceOracle::new().unwrap();
318        oracle.api_url = format!("{}/price/v3", server.url());
319
320        let result = oracle.get_price(&client, "JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN").await;
321        assert!(result.is_err());
322        assert_eq!(
323            result.err(),
324            Some(KoraError::RpcError(
325                "No price data from Jupiter for mint JUPyiwrYJFskUPiHa7hkeR8VUtAeFoSYbKedZNsDvCN"
326                    .to_string()
327            ))
328        );
329    }
330
331    #[tokio::test]
332    #[serial]
333    async fn test_jupiter_rate_limit_429() {
334        {
335            let mut api_key_guard = GLOBAL_JUPITER_API_KEY.write();
336            *api_key_guard = Some("test-api-key".to_string());
337        }
338
339        let mut server = Server::new_async().await;
340        let _m = server
341            .mock("GET", "/price/v3")
342            .match_header("x-api-key", "test-api-key")
343            .match_query(Matcher::Any)
344            .with_status(429)
345            .with_body("Too Many Requests")
346            .create();
347
348        let client = Client::new();
349        let mut oracle = JupiterPriceOracle::new().unwrap();
350        oracle.api_url = format!("{}/price/v3", server.url());
351
352        let result = oracle
353            .get_prices(&client, &["So11111111111111111111111111111111111111112".to_string()])
354            .await;
355        assert!(result.is_err());
356        assert_eq!(result.err(), Some(KoraError::RateLimitExceeded));
357    }
358    #[tokio::test]
359    #[serial]
360    async fn test_jupiter_price_exceeds_max_bounds() {
361        {
362            let mut api_key_guard = GLOBAL_JUPITER_API_KEY.write();
363            *api_key_guard = Some("test-api-key".to_string());
364        }
365
366        // SOL price is normal, but the queried token price exceeds MAX_REASONABLE_PRICE
367        let mock_response = r#"{
368            "So11111111111111111111111111111111111111112": {
369                "usdPrice": 100.0,
370                "blockId": 12345,
371                "decimals": 9,
372                "priceChange24h": 2.5
373            },
374            "TokenMintExceedsMaxBounds1111111111111111": {
375                "usdPrice": 9999999.0,
376                "blockId": 12345,
377                "decimals": 6,
378                "priceChange24h": 0.0
379            }
380        }"#;
381
382        let mut server = Server::new_async().await;
383        let _m = server
384            .mock("GET", "/price/v3")
385            .match_header("x-api-key", "test-api-key")
386            .match_query(Matcher::Any)
387            .with_status(200)
388            .with_header("content-type", "application/json")
389            .with_body(mock_response)
390            .create();
391
392        let client = Client::new();
393        let mut oracle = JupiterPriceOracle::new().unwrap();
394        oracle.api_url = format!("{}/price/v3", server.url());
395
396        let result = oracle
397            .get_prices(&client, &["TokenMintExceedsMaxBounds1111111111111111".to_string()])
398            .await;
399        assert!(result.is_err());
400        assert!(
401            matches!(result.err(), Some(KoraError::RpcError(msg)) if msg.contains("exceeds reasonable bounds"))
402        );
403    }
404    #[tokio::test]
405    #[serial]
406    async fn test_jupiter_price_below_min_bounds() {
407        {
408            let mut api_key_guard = GLOBAL_JUPITER_API_KEY.write();
409            *api_key_guard = Some("test-api-key".to_string());
410        }
411
412        // SOL price is normal, but the queried token price is below MIN_REASONABLE_PRICE
413        let mock_response = r#"{
414            "So11111111111111111111111111111111111111112": {
415                "usdPrice": 100.0,
416                "blockId": 12345,
417                "decimals": 9,
418                "priceChange24h": 2.5
419            },
420            "TokenMintBelowMinBounds11111111111111111111": {
421                "usdPrice": 0.0000000001,
422                "blockId": 12345,
423                "decimals": 6,
424                "priceChange24h": 0.0
425            }
426        }"#;
427
428        let mut server = Server::new_async().await;
429        let _m = server
430            .mock("GET", "/price/v3")
431            .match_header("x-api-key", "test-api-key")
432            .match_query(Matcher::Any)
433            .with_status(200)
434            .with_header("content-type", "application/json")
435            .with_body(mock_response)
436            .create();
437
438        let client = Client::new();
439        let mut oracle = JupiterPriceOracle::new().unwrap();
440        oracle.api_url = format!("{}/price/v3", server.url());
441
442        let result = oracle
443            .get_prices(&client, &["TokenMintBelowMinBounds11111111111111111111".to_string()])
444            .await;
445        assert!(result.is_err());
446        assert!(
447            matches!(result.err(), Some(KoraError::RpcError(msg)) if msg.contains("below reasonable bounds"))
448        );
449    }
450}