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
25fn 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 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 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 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 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 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 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 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}