riglr_web_tools/
price.rs

1//! Price fetching tools using DexScreener API
2//!
3//! This module provides specialized tools for fetching token prices using real DexScreener
4//! integration. It focuses on finding the most reliable price data by selecting pairs
5//! with highest liquidity for accuracy.
6
7use 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/// Response from DexScreener API
16#[derive(Debug, Deserialize)]
17struct DexScreenerResponse {
18    pairs: Option<Vec<PairInfo>>,
19}
20
21/// Pair information from DexScreener
22#[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/// Liquidity information
36#[derive(Debug, Deserialize)]
37struct LiquidityInfo {
38    usd: Option<f64>,
39}
40
41/// Token information
42#[derive(Debug, Deserialize)]
43struct TokenInfo {
44    _address: String,
45    symbol: String,
46}
47
48/// Price result with additional metadata
49#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
50pub struct TokenPriceResult {
51    /// Token address that was queried
52    pub token_address: String,
53    /// Token symbol
54    pub token_symbol: Option<String>,
55    /// Current price in USD
56    pub price_usd: String,
57    /// DEX where price was sourced
58    pub source_dex: Option<String>,
59    /// Pair address used for pricing
60    pub source_pair: Option<String>,
61    /// Liquidity in USD of the source pair
62    pub source_liquidity_usd: Option<f64>,
63    /// Chain name where token exists
64    pub chain: Option<String>,
65    /// Timestamp of price fetch
66    pub fetched_at: chrono::DateTime<chrono::Utc>,
67}
68
69/// Get token price from DexScreener with highest liquidity pair
70///
71/// This tool fetches the most reliable token price by finding the trading pair
72/// with the highest liquidity on DexScreener. Using the highest liquidity pair
73/// ensures the most accurate and stable price data.
74///
75/// # Arguments
76///
77/// * `token_address` - Token contract address to get price for
78/// * `chain` - Optional chain name (e.g., "ethereum", "bsc", "polygon", "solana")
79///
80/// # Returns
81///
82/// Returns `TokenPriceResult` containing:
83/// - `token_address`: The queried token address
84/// - `token_symbol`: Token symbol if available
85/// - `price_usd`: Current price in USD as string
86/// - `source_dex`: DEX where price was sourced from
87/// - `source_pair`: Trading pair address used
88/// - `source_liquidity_usd`: Liquidity of the source pair
89/// - `chain`: Chain name
90/// - `fetched_at`: Timestamp when price was fetched
91///
92/// # Errors
93///
94/// * `ToolError::InvalidInput` - When token address format is invalid
95/// * `ToolError::Retriable` - When DexScreener API request fails
96/// * `ToolError::Permanent` - When no trading pairs found for token
97///
98/// # Examples
99///
100/// ```rust,ignore
101/// use riglr_web_tools::price::get_token_price;
102///
103/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
104/// // Get USDC price on Ethereum
105/// let price = get_token_price(
106///     "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(),
107///     Some("ethereum".to_string()),
108/// ).await?;
109///
110/// println!("USDC price: ${}", price.price_usd);
111/// println!("Source: {} (${:.2} liquidity)",
112///          price.source_dex.unwrap_or_default(),
113///          price.source_liquidity_usd.unwrap_or(0.0));
114/// # Ok(())
115/// # }
116/// ```
117#[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    // Validate token address format
129    if token_address.is_empty() {
130        return Err(ToolError::invalid_input_string(
131            "Token address cannot be empty",
132        ));
133    }
134
135    // Build query string
136    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    // Use WebClient for HTTP request with retry logic
147    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    // Find pair with highest liquidity for most reliable price
158    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()) // Only consider pairs with price data
167                    .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/// Get multiple token prices in a batch request
209///
210/// This tool fetches prices for multiple tokens efficiently by making multiple
211/// requests concurrently. Useful for portfolio tracking or multi-token analysis.
212///
213/// # Arguments
214///
215/// * `token_addresses` - List of token addresses to get prices for
216/// * `chain` - Optional chain name to apply to all tokens
217///
218/// # Returns
219///
220/// Returns `Vec<TokenPriceResult>` with prices for all found tokens.
221/// Tokens without available price data are omitted from results.
222///
223/// # Errors
224///
225/// * `ToolError::InvalidInput` - When token addresses list is empty
226/// * `ToolError::Retriable` - When API requests fail
227///
228/// # Examples
229///
230/// ```rust,ignore
231/// use riglr_web_tools::price::get_token_prices_batch;
232///
233/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
234/// let tokens = vec![
235///     "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48".to_string(), // USDC
236///     "0xdAC17F958D2ee523a2206206994597C13D831ec7".to_string(), // USDT
237/// ];
238///
239/// let prices = get_token_prices_batch(tokens, Some("ethereum".to_string())).await?;
240///
241/// for price in prices {
242///     println!("{}: ${}", price.token_symbol.unwrap_or_default(), price.price_usd);
243/// }
244/// # Ok(())
245/// # }
246/// ```
247#[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    // Create futures for concurrent requests
262    let futures: Vec<_> = token_addresses
263        .into_iter()
264        .map(|addr| get_token_price(context, addr, chain.clone()))
265        .collect();
266
267    // Execute all requests concurrently
268    let results = future::join_all(futures).await;
269
270    // Collect successful results
271    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                // Continue with other tokens
278            }
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        // Create a minimal config with bridging disabled to avoid LIFI_API_KEY requirement
292        let mut features = riglr_core::FeaturesConfig::default();
293        features.enable_bridging = false; // Disable bridging to avoid requiring LIFI_API_KEY
294
295        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        // This will test the query building with chain
340        let context = create_test_context();
341        let result =
342            get_token_price(&context, "0x123".to_string(), Some("ethereum".to_string())).await;
343        // This will likely fail due to no mock, but tests the path with chain
344        assert!(result.is_err());
345    }
346
347    #[tokio::test]
348    async fn test_get_token_price_without_chain() {
349        // This will test the query building without chain
350        let context = create_test_context();
351        let result = get_token_price(&context, "0x123".to_string(), None).await;
352        // This will likely fail due to no mock, but tests the path without chain
353        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        // Will test the batch functionality with one address
362        assert!(result.is_ok()); // Should return empty vec due to failed individual requests
363        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        // Will test the batch functionality with multiple addresses
377        assert!(result.is_ok());
378        assert_eq!(result.unwrap().len(), 0); // All will fail but function succeeds
379    }
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        // Test Clone trait
535        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        // Test Debug trait
541        let debug_str = format!("{:?}", result);
542        assert!(debug_str.contains("TokenPriceResult"));
543        assert!(debug_str.contains("TEST"));
544    }
545}