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