1use crate::client::WebClient;
8use futures::future;
9use riglr_core::ToolError;
10use riglr_macros::tool;
11use schemars::JsonSchema;
12use serde::{Deserialize, Serialize};
13use tracing::{debug, info, warn};
14
15#[derive(Debug, Deserialize)]
17struct DexScreenerResponse {
18 pairs: Option<Vec<PairInfo>>,
19}
20
21#[derive(Debug, Deserialize)]
23struct PairInfo {
24 #[serde(rename = "priceUsd")]
25 price_usd: Option<String>,
26 liquidity: Option<LiquidityInfo>,
27 #[serde(rename = "baseToken")]
28 base_token: TokenInfo,
29 #[serde(rename = "dexId")]
30 dex_id: String,
31 #[serde(rename = "pairAddress")]
32 pair_address: String,
33}
34
35#[derive(Debug, Deserialize)]
37struct LiquidityInfo {
38 usd: Option<f64>,
39}
40
41#[derive(Debug, Deserialize)]
43struct TokenInfo {
44 _address: String,
45 symbol: String,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
50pub struct TokenPriceResult {
51 pub token_address: String,
53 pub token_symbol: Option<String>,
55 pub price_usd: String,
57 pub source_dex: Option<String>,
59 pub source_pair: Option<String>,
61 pub source_liquidity_usd: Option<f64>,
63 pub chain: Option<String>,
65 pub fetched_at: chrono::DateTime<chrono::Utc>,
67}
68
69#[tool]
118pub async fn get_token_price(
119 _context: &riglr_core::provider::ApplicationContext,
120 token_address: String,
121 chain: Option<String>,
122) -> Result<TokenPriceResult, ToolError> {
123 debug!(
124 "Getting token price for address: {} on chain: {:?}",
125 token_address, chain
126 );
127
128 if token_address.is_empty() {
130 return Err(ToolError::invalid_input_string(
131 "Token address cannot be empty",
132 ));
133 }
134
135 let query = if let Some(chain_name) = &chain {
137 format!("{}:{}", chain_name, token_address)
138 } else {
139 token_address.clone()
140 };
141
142 let url = format!("https://api.dexscreener.com/latest/dex/search/?q={}", query);
143
144 debug!("Fetching price data from: {}", url);
145
146 let client = WebClient::default();
148
149 let response_text = client
150 .get(&url)
151 .await
152 .map_err(|e| ToolError::retriable_string(format!("DexScreener request failed: {}", e)))?;
153
154 let data: DexScreenerResponse = serde_json::from_str(&response_text)
155 .map_err(|e| ToolError::retriable_string(format!("Failed to parse response: {}", e)))?;
156
157 let best_pair = data
159 .pairs
160 .and_then(|pairs| {
161 if pairs.is_empty() {
162 None
163 } else {
164 pairs
165 .into_iter()
166 .filter(|pair| pair.price_usd.is_some()) .max_by(|a, b| {
168 let liquidity_a = a.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0);
169 let liquidity_b = b.liquidity.as_ref().and_then(|l| l.usd).unwrap_or(0.0);
170 liquidity_a
171 .partial_cmp(&liquidity_b)
172 .unwrap_or(std::cmp::Ordering::Equal)
173 })
174 }
175 })
176 .ok_or_else(|| ToolError::permanent_string("No trading pairs found for token"))?;
177
178 let price = best_pair
179 .price_usd
180 .ok_or_else(|| ToolError::permanent_string("No price data available"))?;
181
182 let result = TokenPriceResult {
183 token_address: token_address.clone(),
184 token_symbol: Some(best_pair.base_token.symbol),
185 price_usd: price,
186 source_dex: Some(best_pair.dex_id),
187 source_pair: Some(best_pair.pair_address),
188 source_liquidity_usd: best_pair.liquidity.and_then(|l| l.usd),
189 chain: chain.clone(),
190 fetched_at: chrono::Utc::now(),
191 };
192
193 info!(
194 "Found price for {} ({}): ${} from {} DEX with ${:.2} liquidity",
195 token_address,
196 result
197 .token_symbol
198 .as_ref()
199 .unwrap_or(&"Unknown".to_string()),
200 result.price_usd,
201 result.source_dex.as_ref().unwrap_or(&"Unknown".to_string()),
202 result.source_liquidity_usd.unwrap_or(0.0)
203 );
204
205 Ok(result)
206}
207
208#[tool]
248pub async fn get_token_prices_batch(
249 context: &riglr_core::provider::ApplicationContext,
250 token_addresses: Vec<String>,
251 chain: Option<String>,
252) -> Result<Vec<TokenPriceResult>, ToolError> {
253 if token_addresses.is_empty() {
254 return Err(ToolError::invalid_input_string(
255 "Token addresses list cannot be empty",
256 ));
257 }
258
259 debug!("Getting batch prices for {} tokens", token_addresses.len());
260
261 let futures: Vec<_> = token_addresses
263 .into_iter()
264 .map(|addr| get_token_price(context, addr, chain.clone()))
265 .collect();
266
267 let results = future::join_all(futures).await;
269
270 let mut prices = Vec::new();
272 for (i, result) in results.into_iter().enumerate() {
273 match result {
274 Ok(price) => prices.push(price),
275 Err(e) => {
276 warn!("Failed to get price for token {}: {}", i, e);
277 }
279 }
280 }
281
282 info!("Successfully retrieved {} token prices", prices.len());
283 Ok(prices)
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 fn create_test_context() -> riglr_core::provider::ApplicationContext {
291 let mut features = riglr_core::FeaturesConfig::default();
293 features.enable_bridging = false; let config = riglr_config::ConfigBuilder::new()
296 .features(features)
297 .build()
298 .expect("Test config should be valid");
299
300 riglr_core::provider::ApplicationContext::from_config(&config)
301 }
302
303 #[test]
304 fn test_token_price_result_creation() {
305 let result = TokenPriceResult {
306 token_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
307 token_symbol: Some("USDC".to_string()),
308 price_usd: "1.0000".to_string(),
309 source_dex: Some("uniswap_v2".to_string()),
310 source_pair: Some("0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc".to_string()),
311 source_liquidity_usd: Some(10000000.0),
312 chain: Some("ethereum".to_string()),
313 fetched_at: chrono::Utc::now(),
314 };
315
316 assert_eq!(result.token_symbol, Some("USDC".to_string()));
317 assert_eq!(result.price_usd, "1.0000");
318 assert!(result.source_liquidity_usd.unwrap() > 0.0);
319 }
320
321 #[tokio::test]
322 async fn test_empty_token_address_validation() {
323 let context = create_test_context();
324 let result = get_token_price(&context, "".to_string(), None).await;
325 assert!(result.is_err());
326 assert!(matches!(result, Err(ToolError::InvalidInput { .. })));
327 }
328
329 #[tokio::test]
330 async fn test_batch_empty_addresses() {
331 let context = create_test_context();
332 let result = get_token_prices_batch(&context, vec![], None).await;
333 assert!(result.is_err());
334 assert!(matches!(result, Err(ToolError::InvalidInput { .. })));
335 }
336
337 #[tokio::test]
338 async fn test_get_token_price_with_chain() {
339 let context = create_test_context();
341 let result =
342 get_token_price(&context, "0x123".to_string(), Some("ethereum".to_string())).await;
343 assert!(result.is_err());
345 }
346
347 #[tokio::test]
348 async fn test_get_token_price_without_chain() {
349 let context = create_test_context();
351 let result = get_token_price(&context, "0x123".to_string(), None).await;
352 assert!(result.is_err());
354 }
355
356 #[tokio::test]
357 async fn test_batch_with_single_address() {
358 let context = create_test_context();
359 let addresses = vec!["0x123".to_string()];
360 let result = get_token_prices_batch(&context, addresses, None).await;
361 assert!(result.is_ok()); assert_eq!(result.unwrap().len(), 0);
364 }
365
366 #[tokio::test]
367 async fn test_batch_with_multiple_addresses() {
368 let context = create_test_context();
369 let addresses = vec![
370 "0x123".to_string(),
371 "0x456".to_string(),
372 "0x789".to_string(),
373 ];
374 let result =
375 get_token_prices_batch(&context, addresses, Some("ethereum".to_string())).await;
376 assert!(result.is_ok());
378 assert_eq!(result.unwrap().len(), 0); }
380
381 #[test]
382 fn test_dexscreener_response_deserialization_empty_pairs() {
383 let json = r#"{"pairs": []}"#;
384 let response: DexScreenerResponse = serde_json::from_str(json).unwrap();
385 assert!(response.pairs.is_some());
386 assert!(response.pairs.unwrap().is_empty());
387 }
388
389 #[test]
390 fn test_dexscreener_response_deserialization_no_pairs() {
391 let json = r#"{"pairs": null}"#;
392 let response: DexScreenerResponse = serde_json::from_str(json).unwrap();
393 assert!(response.pairs.is_none());
394 }
395
396 #[test]
397 fn test_pair_info_deserialization_complete() {
398 let json = r#"{
399 "priceUsd": "1.0000",
400 "liquidity": {"usd": 10000.0},
401 "baseToken": {"address": "0x123", "symbol": "TEST"},
402 "dexId": "uniswap_v2",
403 "pairAddress": "0x456"
404 }"#;
405 let pair: PairInfo = serde_json::from_str(json).unwrap();
406 assert_eq!(pair.price_usd, Some("1.0000".to_string()));
407 assert_eq!(pair.liquidity.unwrap().usd, Some(10000.0));
408 assert_eq!(pair.base_token.symbol, "TEST");
409 assert_eq!(pair.dex_id, "uniswap_v2");
410 assert_eq!(pair.pair_address, "0x456");
411 }
412
413 #[test]
414 fn test_pair_info_deserialization_minimal() {
415 let json = r#"{
416 "priceUsd": null,
417 "liquidity": null,
418 "baseToken": {"address": "0x123", "symbol": "TEST"},
419 "dexId": "uniswap_v2",
420 "pairAddress": "0x456"
421 }"#;
422 let pair: PairInfo = serde_json::from_str(json).unwrap();
423 assert_eq!(pair.price_usd, None);
424 assert!(pair.liquidity.is_none());
425 assert_eq!(pair.base_token.symbol, "TEST");
426 }
427
428 #[test]
429 fn test_liquidity_info_deserialization_with_usd() {
430 let json = r#"{"usd": 50000.0}"#;
431 let liquidity: LiquidityInfo = serde_json::from_str(json).unwrap();
432 assert_eq!(liquidity.usd, Some(50000.0));
433 }
434
435 #[test]
436 fn test_liquidity_info_deserialization_without_usd() {
437 let json = r#"{"usd": null}"#;
438 let liquidity: LiquidityInfo = serde_json::from_str(json).unwrap();
439 assert_eq!(liquidity.usd, None);
440 }
441
442 #[test]
443 fn test_token_info_deserialization() {
444 let json = r#"{"address": "0x123", "symbol": "BTC"}"#;
445 let token: TokenInfo = serde_json::from_str(json).unwrap();
446 assert_eq!(token._address, "0x123");
447 assert_eq!(token.symbol, "BTC");
448 }
449
450 #[test]
451 fn test_token_price_result_serialization() {
452 let result = TokenPriceResult {
453 token_address: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
454 token_symbol: Some("USDC".to_string()),
455 price_usd: "1.0000".to_string(),
456 source_dex: Some("uniswap_v2".to_string()),
457 source_pair: Some("0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc".to_string()),
458 source_liquidity_usd: Some(10000000.0),
459 chain: Some("ethereum".to_string()),
460 fetched_at: chrono::DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z")
461 .unwrap()
462 .with_timezone(&chrono::Utc),
463 };
464
465 let serialized = serde_json::to_string(&result).unwrap();
466 assert!(serialized.contains("USDC"));
467 assert!(serialized.contains("1.0000"));
468 assert!(serialized.contains("ethereum"));
469 }
470
471 #[test]
472 fn test_token_price_result_deserialization() {
473 let json = r#"{
474 "token_address": "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
475 "token_symbol": "USDC",
476 "price_usd": "1.0000",
477 "source_dex": "uniswap_v2",
478 "source_pair": "0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc",
479 "source_liquidity_usd": 10000000.0,
480 "chain": "ethereum",
481 "fetched_at": "2023-01-01T00:00:00Z"
482 }"#;
483
484 let result: TokenPriceResult = serde_json::from_str(json).unwrap();
485 assert_eq!(
486 result.token_address,
487 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
488 );
489 assert_eq!(result.token_symbol, Some("USDC".to_string()));
490 assert_eq!(result.price_usd, "1.0000");
491 assert_eq!(result.source_dex, Some("uniswap_v2".to_string()));
492 assert_eq!(
493 result.source_pair,
494 Some("0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc".to_string())
495 );
496 assert_eq!(result.source_liquidity_usd, Some(10000000.0));
497 assert_eq!(result.chain, Some("ethereum".to_string()));
498 }
499
500 #[test]
501 fn test_token_price_result_with_optional_none_fields() {
502 let result = TokenPriceResult {
503 token_address: "0x123".to_string(),
504 token_symbol: None,
505 price_usd: "0.5".to_string(),
506 source_dex: None,
507 source_pair: None,
508 source_liquidity_usd: None,
509 chain: None,
510 fetched_at: chrono::Utc::now(),
511 };
512
513 assert_eq!(result.token_symbol, None);
514 assert_eq!(result.source_dex, None);
515 assert_eq!(result.source_pair, None);
516 assert_eq!(result.source_liquidity_usd, None);
517 assert_eq!(result.chain, None);
518 assert_eq!(result.price_usd, "0.5");
519 }
520
521 #[test]
522 fn test_clone_and_debug_traits() {
523 let result = TokenPriceResult {
524 token_address: "0x123".to_string(),
525 token_symbol: Some("TEST".to_string()),
526 price_usd: "1.0".to_string(),
527 source_dex: Some("test_dex".to_string()),
528 source_pair: Some("0x456".to_string()),
529 source_liquidity_usd: Some(1000.0),
530 chain: Some("test_chain".to_string()),
531 fetched_at: chrono::Utc::now(),
532 };
533
534 let cloned = result.clone();
536 assert_eq!(result.token_address, cloned.token_address);
537 assert_eq!(result.token_symbol, cloned.token_symbol);
538 assert_eq!(result.price_usd, cloned.price_usd);
539
540 let debug_str = format!("{:?}", result);
542 assert!(debug_str.contains("TokenPriceResult"));
543 assert!(debug_str.contains("TEST"));
544 }
545}