1use crate::error::{Result, ScopeError};
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34use std::path::PathBuf;
35
36pub const STABLECOIN_SYMBOLS: [&str; 10] = [
39 "USDC", "USDT", "DAI", "BUSD", "TUSD", "USDP", "FRAX", "LUSD", "GUSD", "FDUSD",
40];
41
42pub fn is_stablecoin_symbol(symbol: &str) -> bool {
45 let upper = symbol.to_uppercase();
46 STABLECOIN_SYMBOLS.contains(&upper.as_str())
47}
48
49const STABLECOIN_PRICE_RANGE: std::ops::RangeInclusive<f64> = 0.95..=1.05;
51
52pub fn is_stablecoin_price(price: f64) -> bool {
54 STABLECOIN_PRICE_RANGE.contains(&price)
55}
56
57#[derive(Debug, Clone, Serialize, Deserialize)]
59pub struct TokenInfo {
60 pub address: String,
62
63 pub symbol: String,
65
66 pub name: String,
68
69 pub chain: String,
71
72 #[serde(default)]
74 pub last_used: Option<i64>,
75}
76
77#[derive(Debug, Clone, Default, Serialize, Deserialize)]
79pub struct TokenAliases {
80 #[serde(default)]
83 aliases: HashMap<String, HashMap<String, TokenInfo>>,
84
85 #[serde(default)]
87 recent: Vec<TokenInfo>,
88}
89
90impl TokenAliases {
91 pub fn aliases_path() -> Option<PathBuf> {
93 crate::paths::data_dir().map(|p| p.join("scope").join("tokens.yaml"))
94 }
95
96 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 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 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 self.aliases
142 .entry(alias_key)
143 .or_default()
144 .insert(chain_key, info.clone());
145
146 self.recent
148 .retain(|t| !(t.symbol == info.symbol && t.chain == info.chain));
149 self.recent.insert(0, info);
150
151 self.recent.truncate(20);
153 }
154
155 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 chain_map
175 .get("ethereum")
176 .or_else(|| chain_map.values().next())
177 }
178 } else {
179 None
180 }
181 }
182
183 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 pub fn recent(&self) -> &[TokenInfo] {
194 &self.recent
195 }
196
197 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 pub fn list(&self) -> Vec<&TokenInfo> {
219 self.aliases
220 .values()
221 .flat_map(|chain_map| chain_map.values())
222 .collect()
223 }
224
225 pub fn is_address(input: &str) -> bool {
227 if input.starts_with("0x") && input.len() == 42 {
229 return input[2..].chars().all(|c| c.is_ascii_hexdigit());
230 }
231
232 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 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#[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 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 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 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 aliases.remove("USDC", None);
309 assert!(aliases.get("USDC", None).is_none());
310 }
311
312 #[test]
313 fn test_is_address() {
314 assert!(TokenAliases::is_address(
316 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
317 ));
318 assert!(TokenAliases::is_address(
319 "0x0000000000000000000000000000000000000000"
320 ));
321
322 assert!(!TokenAliases::is_address("USDC"));
324 assert!(!TokenAliases::is_address("ethereum"));
325 assert!(!TokenAliases::is_address("0x123")); }
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 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 assert!(TokenAliases::is_address(
373 "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"
374 ));
375 assert!(TokenAliases::is_address("11111111111111111111111111111111"));
377 }
378
379 #[test]
380 fn test_is_address_tron() {
381 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("")); assert!(!TokenAliases::is_address("0x")); assert!(!TokenAliases::is_address("T123")); assert!(!TokenAliases::is_address("hello world")); }
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 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 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 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 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 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 let result = aliases.save();
486 assert!(result.is_ok());
487
488 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 aliases.remove("SAVE_TEST", None);
496 let _ = aliases.save();
497 }
498}