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
25pub 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
33fn 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 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 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 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 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 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 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 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 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 {
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 {
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 {
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}