Skip to main content

scope/
tokens.rs

1//! # Token Alias Storage
2//!
3//! This module provides storage and retrieval of token aliases,
4//! allowing users to reference tokens by friendly names instead
5//! of full contract addresses.
6//!
7//! ## Storage Location
8//!
9//! Token aliases are stored in `~/.local/share/scope/tokens.yaml`
10//!
11//! ## Usage
12//!
13//! ```rust,no_run
14//! use scope::tokens::TokenAliases;
15//!
16//! // Load existing aliases
17//! let mut aliases = TokenAliases::load();
18//!
19//! // Add an alias
20//! aliases.add("USDC", "ethereum", "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48", "USD Coin");
21//!
22//! // Look up an alias
23//! if let Some(info) = aliases.get("USDC", Some("ethereum")) {
24//!     println!("USDC address: {}", info.address);
25//! }
26//!
27//! // Save aliases
28//! aliases.save().unwrap();
29//! ```
30
31use crate::error::{Result, ScopeError};
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34use std::path::PathBuf;
35
36/// Symbols treated as USD stablecoins, in upper case. Market pair parsing
37/// reads them as quote currencies too (`market::pair`).
38pub const STABLECOIN_SYMBOLS: [&str; 10] = [
39    "USDC", "USDT", "DAI", "BUSD", "TUSD", "USDP", "FRAX", "LUSD", "GUSD", "FDUSD",
40];
41
42/// Whether `symbol` is a known USD stablecoin (case-insensitive). The one
43/// symbol list: insights, the monitor and the ticker volume rules use it.
44pub fn is_stablecoin_symbol(symbol: &str) -> bool {
45    let upper = symbol.to_uppercase();
46    STABLECOIN_SYMBOLS.contains(&upper.as_str())
47}
48
49/// A price that counts as a stablecoin's: within this range of one dollar.
50const STABLECOIN_PRICE_RANGE: std::ops::RangeInclusive<f64> = 0.95..=1.05;
51
52/// Whether `price` (in USD) looks like a stablecoin's price.
53pub fn is_stablecoin_price(price: f64) -> bool {
54    STABLECOIN_PRICE_RANGE.contains(&price)
55}
56
57/// A saved token alias with its address and metadata.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct TokenInfo {
60    /// Token contract address.
61    pub address: String,
62
63    /// Token symbol.
64    pub symbol: String,
65
66    /// Token name.
67    pub name: String,
68
69    /// Blockchain network.
70    pub chain: String,
71
72    /// When this alias was last used.
73    #[serde(default)]
74    pub last_used: Option<i64>,
75}
76
77/// Collection of token aliases.
78#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79pub struct TokenAliases {
80    /// Map of alias -> chain -> token info.
81    /// Using nested maps to support same symbol on different chains.
82    #[serde(default)]
83    aliases: HashMap<String, HashMap<String, TokenInfo>>,
84
85    /// Recent tokens for quick access.
86    #[serde(default)]
87    recent: Vec<TokenInfo>,
88}
89
90impl TokenAliases {
91    /// Returns the path to the token aliases file.
92    pub fn aliases_path() -> Option<PathBuf> {
93        crate::paths::data_dir().map(|p| p.join("scope").join("tokens.yaml"))
94    }
95
96    /// Loads token aliases from disk.
97    pub fn load() -> Self {
98        Self::aliases_path()
99            .and_then(|path| std::fs::read_to_string(&path).ok())
100            .and_then(|contents| serde_yaml::from_str(&contents).ok())
101            .unwrap_or_default()
102    }
103
104    /// Saves token aliases to disk.
105    pub fn save(&self) -> Result<()> {
106        if let Some(path) = Self::aliases_path() {
107            if let Some(parent) = path.parent() {
108                std::fs::create_dir_all(parent).map_err(|e| {
109                    ScopeError::io_context(e, "Failed to create token aliases directory")
110                })?;
111            }
112            let contents = serde_yaml::to_string(self)
113                .map_err(|e| ScopeError::Export(format!("Failed to serialize aliases: {}", e)))?;
114            std::fs::write(&path, contents)
115                .map_err(|e| ScopeError::io_context(e, "Failed to write token aliases"))?;
116        }
117        Ok(())
118    }
119
120    /// Adds or updates a token alias.
121    ///
122    /// # Arguments
123    ///
124    /// * `alias` - The friendly name/symbol to use (case-insensitive)
125    /// * `chain` - The blockchain network
126    /// * `address` - The token contract address
127    /// * `name` - The full token name
128    pub fn add(&mut self, alias: &str, chain: &str, address: &str, name: &str) {
129        let alias_key = alias.to_uppercase();
130        let chain_key = chain.to_lowercase();
131
132        let info = TokenInfo {
133            address: address.to_string(),
134            symbol: alias.to_uppercase(),
135            name: name.to_string(),
136            chain: chain_key.clone(),
137            last_used: Some(chrono::Utc::now().timestamp()),
138        };
139
140        // Add to aliases map
141        self.aliases
142            .entry(alias_key)
143            .or_default()
144            .insert(chain_key, info.clone());
145
146        // Add to recent (remove existing first, then add to front)
147        self.recent
148            .retain(|t| !(t.symbol == info.symbol && t.chain == info.chain));
149        self.recent.insert(0, info);
150
151        // Keep only last 20 recent
152        self.recent.truncate(20);
153    }
154
155    /// Looks up a token alias.
156    ///
157    /// # Arguments
158    ///
159    /// * `alias` - The alias to look up (case-insensitive)
160    /// * `chain` - Optional chain filter. If None, returns first match.
161    ///
162    /// # Returns
163    ///
164    /// Returns the token info if found.
165    pub fn get(&self, alias: &str, chain: Option<&str>) -> Option<&TokenInfo> {
166        let alias_key = alias.to_uppercase();
167
168        if let Some(chain_map) = self.aliases.get(&alias_key) {
169            if let Some(chain) = chain {
170                let chain_key = chain.to_lowercase();
171                chain_map.get(&chain_key)
172            } else {
173                // Return the first one (or prefer ethereum if available)
174                chain_map
175                    .get("ethereum")
176                    .or_else(|| chain_map.values().next())
177            }
178        } else {
179            None
180        }
181    }
182
183    /// Gets all chains that have this alias defined.
184    pub fn get_chains_for_alias(&self, alias: &str) -> Vec<&str> {
185        let alias_key = alias.to_uppercase();
186        self.aliases
187            .get(&alias_key)
188            .map(|chain_map| chain_map.keys().map(|s| s.as_str()).collect())
189            .unwrap_or_default()
190    }
191
192    /// Returns recent tokens.
193    pub fn recent(&self) -> &[TokenInfo] {
194        &self.recent
195    }
196
197    /// Removes an alias.
198    pub fn remove(&mut self, alias: &str, chain: Option<&str>) {
199        let alias_key = alias.to_uppercase();
200
201        if let Some(chain) = chain {
202            let chain_key = chain.to_lowercase();
203            if let Some(chain_map) = self.aliases.get_mut(&alias_key) {
204                chain_map.remove(&chain_key);
205                if chain_map.is_empty() {
206                    self.aliases.remove(&alias_key);
207                }
208            }
209            self.recent
210                .retain(|t| !(t.symbol == alias_key && t.chain == chain_key));
211        } else {
212            self.aliases.remove(&alias_key);
213            self.recent.retain(|t| t.symbol != alias_key);
214        }
215    }
216
217    /// Lists all saved aliases.
218    pub fn list(&self) -> Vec<&TokenInfo> {
219        self.aliases
220            .values()
221            .flat_map(|chain_map| chain_map.values())
222            .collect()
223    }
224
225    /// Checks if the input looks like a token address or a name.
226    pub fn is_address(input: &str) -> bool {
227        // EVM address: 0x + 40 hex chars
228        if input.starts_with("0x") && input.len() == 42 {
229            return input[2..].chars().all(|c| c.is_ascii_hexdigit());
230        }
231
232        // Solana address: base58, 32-44 chars
233        if input.len() >= 32
234            && input.len() <= 44
235            && let Ok(decoded) = bs58::decode(input).into_vec()
236            && decoded.len() == 32
237        {
238            return true;
239        }
240
241        // Tron address: T + 33 chars
242        if input.starts_with('T') && input.len() == 34 {
243            return bs58::decode(input).into_vec().is_ok();
244        }
245
246        false
247    }
248}
249
250// ============================================================================
251// Unit Tests
252// ============================================================================
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_token_aliases_default() {
260        let aliases = TokenAliases::default();
261        assert!(aliases.aliases.is_empty());
262        assert!(aliases.recent.is_empty());
263    }
264
265    #[test]
266    fn test_add_and_get_alias() {
267        let mut aliases = TokenAliases::default();
268        aliases.add(
269            "USDC",
270            "ethereum",
271            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
272            "USD Coin",
273        );
274
275        let info = aliases.get("USDC", Some("ethereum")).unwrap();
276        assert_eq!(info.symbol, "USDC");
277        assert_eq!(info.chain, "ethereum");
278        assert!(info.address.starts_with("0x"));
279
280        // Case insensitive lookup
281        let info2 = aliases.get("usdc", Some("ethereum")).unwrap();
282        assert_eq!(info2.symbol, "USDC");
283    }
284
285    #[test]
286    fn test_get_without_chain() {
287        let mut aliases = TokenAliases::default();
288        aliases.add("USDC", "ethereum", "0xETH...", "USD Coin");
289        aliases.add("USDC", "polygon", "0xPOLY...", "USD Coin");
290
291        // Should prefer ethereum
292        let info = aliases.get("USDC", None).unwrap();
293        assert_eq!(info.chain, "ethereum");
294    }
295
296    #[test]
297    fn test_remove_alias() {
298        let mut aliases = TokenAliases::default();
299        aliases.add("USDC", "ethereum", "0x...", "USD Coin");
300        aliases.add("USDC", "polygon", "0x...", "USD Coin");
301
302        // Remove specific chain
303        aliases.remove("USDC", Some("ethereum"));
304        assert!(aliases.get("USDC", Some("ethereum")).is_none());
305        assert!(aliases.get("USDC", Some("polygon")).is_some());
306
307        // Remove all chains
308        aliases.remove("USDC", None);
309        assert!(aliases.get("USDC", None).is_none());
310    }
311
312    #[test]
313    fn test_is_address() {
314        // EVM addresses
315        assert!(TokenAliases::is_address(
316            "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
317        ));
318        assert!(TokenAliases::is_address(
319            "0x0000000000000000000000000000000000000000"
320        ));
321
322        // Not addresses
323        assert!(!TokenAliases::is_address("USDC"));
324        assert!(!TokenAliases::is_address("ethereum"));
325        assert!(!TokenAliases::is_address("0x123")); // Too short
326    }
327
328    #[test]
329    fn test_recent_tokens() {
330        let mut aliases = TokenAliases::default();
331        aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
332        aliases.add("WETH", "ethereum", "0x2...", "Wrapped Ether");
333
334        assert_eq!(aliases.recent().len(), 2);
335        // Most recent first
336        assert_eq!(aliases.recent()[0].symbol, "WETH");
337    }
338
339    #[test]
340    fn test_list_aliases() {
341        let mut aliases = TokenAliases::default();
342        aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
343        aliases.add("USDC", "polygon", "0x2...", "USD Coin");
344        aliases.add("WETH", "ethereum", "0x3...", "Wrapped Ether");
345
346        let list = aliases.list();
347        assert_eq!(list.len(), 3);
348    }
349
350    #[test]
351    fn test_get_chains_for_alias() {
352        let mut aliases = TokenAliases::default();
353        aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
354        aliases.add("USDC", "polygon", "0x2...", "USD Coin");
355
356        let chains = aliases.get_chains_for_alias("USDC");
357        assert_eq!(chains.len(), 2);
358        assert!(chains.contains(&"ethereum"));
359        assert!(chains.contains(&"polygon"));
360    }
361
362    #[test]
363    fn test_get_chains_for_missing_alias() {
364        let aliases = TokenAliases::default();
365        let chains = aliases.get_chains_for_alias("NONEXISTENT");
366        assert!(chains.is_empty());
367    }
368
369    #[test]
370    fn test_is_address_solana() {
371        // Valid Solana address (base58, 32-44 chars, decodes to 32 bytes)
372        assert!(TokenAliases::is_address(
373            "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"
374        ));
375        // System program address
376        assert!(TokenAliases::is_address("11111111111111111111111111111111"));
377    }
378
379    #[test]
380    fn test_is_address_tron() {
381        // Valid Tron address (starts with T, 34 chars)
382        assert!(TokenAliases::is_address(
383            "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"
384        ));
385        assert!(TokenAliases::is_address(
386            "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
387        ));
388    }
389
390    #[test]
391    fn test_is_address_edge_cases() {
392        assert!(!TokenAliases::is_address("")); // Empty
393        assert!(!TokenAliases::is_address("0x")); // Incomplete EVM prefix
394        assert!(!TokenAliases::is_address("T123")); // Too short for Tron
395        assert!(!TokenAliases::is_address("hello world")); // Random text
396    }
397
398    #[test]
399    fn test_remove_specific_chain() {
400        let mut aliases = TokenAliases::default();
401        aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
402        aliases.add("USDC", "polygon", "0x2...", "USD Coin");
403
404        // Remove only polygon
405        aliases.remove("USDC", Some("polygon"));
406        assert!(aliases.get("USDC", Some("polygon")).is_none());
407        assert!(aliases.get("USDC", Some("ethereum")).is_some());
408    }
409
410    #[test]
411    fn test_remove_last_chain_cleans_up() {
412        let mut aliases = TokenAliases::default();
413        aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
414
415        // Removing the only chain should clean up the alias entirely
416        aliases.remove("USDC", Some("ethereum"));
417        assert!(aliases.get("USDC", None).is_none());
418        let chains = aliases.get_chains_for_alias("USDC");
419        assert!(chains.is_empty());
420    }
421
422    #[test]
423    fn test_remove_cleans_recent() {
424        let mut aliases = TokenAliases::default();
425        aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
426        assert_eq!(aliases.recent().len(), 1);
427
428        aliases.remove("USDC", None);
429        assert!(aliases.recent().is_empty());
430    }
431
432    #[test]
433    fn test_add_updates_existing() {
434        let mut aliases = TokenAliases::default();
435        aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
436        aliases.add("USDC", "ethereum", "0x2...", "USD Coin V2");
437
438        let info = aliases.get("USDC", Some("ethereum")).unwrap();
439        assert_eq!(info.address, "0x2...");
440        assert_eq!(info.name, "USD Coin V2");
441    }
442
443    #[test]
444    fn test_recent_truncation() {
445        let mut aliases = TokenAliases::default();
446        // Add 25 tokens, recent should be capped at 20
447        for i in 0..25 {
448            aliases.add(
449                &format!("T{}", i),
450                "ethereum",
451                &format!("0x{}...", i),
452                &format!("Token {}", i),
453            );
454        }
455        assert_eq!(aliases.recent().len(), 20);
456        // The most recent should be T24
457        assert_eq!(aliases.recent()[0].symbol, "T24");
458    }
459
460    #[test]
461    fn test_case_insensitive_operations() {
462        let mut aliases = TokenAliases::default();
463        aliases.add("usdc", "Ethereum", "0x1...", "USD Coin");
464
465        // Alias stored uppercase, chain stored lowercase
466        let info = aliases.get("USDC", Some("ethereum")).unwrap();
467        assert_eq!(info.symbol, "USDC");
468        assert_eq!(info.chain, "ethereum");
469    }
470
471    #[test]
472    fn test_token_info_has_last_used() {
473        let mut aliases = TokenAliases::default();
474        aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
475        let info = aliases.get("USDC", Some("ethereum")).unwrap();
476        assert!(info.last_used.is_some());
477    }
478
479    #[test]
480    fn test_save_and_load_roundtrip() {
481        let mut aliases = TokenAliases::default();
482        aliases.add("SAVE_TEST", "ethereum", "0xsave...", "Save Test Token");
483
484        // save() writes to the standard location which should be writable in test env
485        let result = aliases.save();
486        assert!(result.is_ok());
487
488        // Load it back
489        let loaded = TokenAliases::load();
490        let info = loaded.get("SAVE_TEST", Some("ethereum"));
491        assert!(info.is_some());
492        assert_eq!(info.unwrap().address, "0xsave...");
493
494        // Cleanup: remove the test entry and save again
495        aliases.remove("SAVE_TEST", None);
496        let _ = aliases.save();
497    }
498}