1use crate::error::{Result, ScopeError};
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34use std::path::PathBuf;
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct TokenInfo {
39 pub address: String,
41
42 pub symbol: String,
44
45 pub name: String,
47
48 pub chain: String,
50
51 #[serde(default)]
53 pub last_used: Option<i64>,
54}
55
56#[derive(Debug, Clone, Default, Serialize, Deserialize)]
58pub struct TokenAliases {
59 #[serde(default)]
62 aliases: HashMap<String, HashMap<String, TokenInfo>>,
63
64 #[serde(default)]
66 recent: Vec<TokenInfo>,
67}
68
69impl TokenAliases {
70 pub fn aliases_path() -> Option<PathBuf> {
72 dirs::data_dir().map(|p| p.join("scope").join("tokens.yaml"))
73 }
74
75 pub fn load() -> Self {
77 Self::aliases_path()
78 .and_then(|path| std::fs::read_to_string(&path).ok())
79 .and_then(|contents| serde_yaml::from_str(&contents).ok())
80 .unwrap_or_default()
81 }
82
83 pub fn save(&self) -> Result<()> {
85 if let Some(path) = Self::aliases_path() {
86 if let Some(parent) = path.parent() {
87 std::fs::create_dir_all(parent).map_err(|e| {
88 ScopeError::Io(format!("Failed to create token aliases directory: {}", e))
89 })?;
90 }
91 let contents = serde_yaml::to_string(self)
92 .map_err(|e| ScopeError::Export(format!("Failed to serialize aliases: {}", e)))?;
93 std::fs::write(&path, contents)
94 .map_err(|e| ScopeError::Io(format!("Failed to write token aliases: {}", e)))?;
95 }
96 Ok(())
97 }
98
99 pub fn add(&mut self, alias: &str, chain: &str, address: &str, name: &str) {
108 let alias_key = alias.to_uppercase();
109 let chain_key = chain.to_lowercase();
110
111 let info = TokenInfo {
112 address: address.to_string(),
113 symbol: alias.to_uppercase(),
114 name: name.to_string(),
115 chain: chain_key.clone(),
116 last_used: Some(chrono::Utc::now().timestamp()),
117 };
118
119 self.aliases
121 .entry(alias_key)
122 .or_default()
123 .insert(chain_key, info.clone());
124
125 self.recent
127 .retain(|t| !(t.symbol == info.symbol && t.chain == info.chain));
128 self.recent.insert(0, info);
129
130 self.recent.truncate(20);
132 }
133
134 pub fn get(&self, alias: &str, chain: Option<&str>) -> Option<&TokenInfo> {
145 let alias_key = alias.to_uppercase();
146
147 if let Some(chain_map) = self.aliases.get(&alias_key) {
148 if let Some(chain) = chain {
149 let chain_key = chain.to_lowercase();
150 chain_map.get(&chain_key)
151 } else {
152 chain_map
154 .get("ethereum")
155 .or_else(|| chain_map.values().next())
156 }
157 } else {
158 None
159 }
160 }
161
162 pub fn get_chains_for_alias(&self, alias: &str) -> Vec<&str> {
164 let alias_key = alias.to_uppercase();
165 self.aliases
166 .get(&alias_key)
167 .map(|chain_map| chain_map.keys().map(|s| s.as_str()).collect())
168 .unwrap_or_default()
169 }
170
171 pub fn recent(&self) -> &[TokenInfo] {
173 &self.recent
174 }
175
176 pub fn remove(&mut self, alias: &str, chain: Option<&str>) {
178 let alias_key = alias.to_uppercase();
179
180 if let Some(chain) = chain {
181 let chain_key = chain.to_lowercase();
182 if let Some(chain_map) = self.aliases.get_mut(&alias_key) {
183 chain_map.remove(&chain_key);
184 if chain_map.is_empty() {
185 self.aliases.remove(&alias_key);
186 }
187 }
188 self.recent
189 .retain(|t| !(t.symbol == alias_key && t.chain == chain_key));
190 } else {
191 self.aliases.remove(&alias_key);
192 self.recent.retain(|t| t.symbol != alias_key);
193 }
194 }
195
196 pub fn list(&self) -> Vec<&TokenInfo> {
198 self.aliases
199 .values()
200 .flat_map(|chain_map| chain_map.values())
201 .collect()
202 }
203
204 pub fn is_address(input: &str) -> bool {
206 if input.starts_with("0x") && input.len() == 42 {
208 return input[2..].chars().all(|c| c.is_ascii_hexdigit());
209 }
210
211 if input.len() >= 32
213 && input.len() <= 44
214 && let Ok(decoded) = bs58::decode(input).into_vec()
215 && decoded.len() == 32
216 {
217 return true;
218 }
219
220 if input.starts_with('T') && input.len() == 34 {
222 return bs58::decode(input).into_vec().is_ok();
223 }
224
225 false
226 }
227}
228
229#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn test_token_aliases_default() {
239 let aliases = TokenAliases::default();
240 assert!(aliases.aliases.is_empty());
241 assert!(aliases.recent.is_empty());
242 }
243
244 #[test]
245 fn test_add_and_get_alias() {
246 let mut aliases = TokenAliases::default();
247 aliases.add(
248 "USDC",
249 "ethereum",
250 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
251 "USD Coin",
252 );
253
254 let info = aliases.get("USDC", Some("ethereum")).unwrap();
255 assert_eq!(info.symbol, "USDC");
256 assert_eq!(info.chain, "ethereum");
257 assert!(info.address.starts_with("0x"));
258
259 let info2 = aliases.get("usdc", Some("ethereum")).unwrap();
261 assert_eq!(info2.symbol, "USDC");
262 }
263
264 #[test]
265 fn test_get_without_chain() {
266 let mut aliases = TokenAliases::default();
267 aliases.add("USDC", "ethereum", "0xETH...", "USD Coin");
268 aliases.add("USDC", "polygon", "0xPOLY...", "USD Coin");
269
270 let info = aliases.get("USDC", None).unwrap();
272 assert_eq!(info.chain, "ethereum");
273 }
274
275 #[test]
276 fn test_remove_alias() {
277 let mut aliases = TokenAliases::default();
278 aliases.add("USDC", "ethereum", "0x...", "USD Coin");
279 aliases.add("USDC", "polygon", "0x...", "USD Coin");
280
281 aliases.remove("USDC", Some("ethereum"));
283 assert!(aliases.get("USDC", Some("ethereum")).is_none());
284 assert!(aliases.get("USDC", Some("polygon")).is_some());
285
286 aliases.remove("USDC", None);
288 assert!(aliases.get("USDC", None).is_none());
289 }
290
291 #[test]
292 fn test_is_address() {
293 assert!(TokenAliases::is_address(
295 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
296 ));
297 assert!(TokenAliases::is_address(
298 "0x0000000000000000000000000000000000000000"
299 ));
300
301 assert!(!TokenAliases::is_address("USDC"));
303 assert!(!TokenAliases::is_address("ethereum"));
304 assert!(!TokenAliases::is_address("0x123")); }
306
307 #[test]
308 fn test_recent_tokens() {
309 let mut aliases = TokenAliases::default();
310 aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
311 aliases.add("WETH", "ethereum", "0x2...", "Wrapped Ether");
312
313 assert_eq!(aliases.recent().len(), 2);
314 assert_eq!(aliases.recent()[0].symbol, "WETH");
316 }
317
318 #[test]
319 fn test_list_aliases() {
320 let mut aliases = TokenAliases::default();
321 aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
322 aliases.add("USDC", "polygon", "0x2...", "USD Coin");
323 aliases.add("WETH", "ethereum", "0x3...", "Wrapped Ether");
324
325 let list = aliases.list();
326 assert_eq!(list.len(), 3);
327 }
328
329 #[test]
330 fn test_get_chains_for_alias() {
331 let mut aliases = TokenAliases::default();
332 aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
333 aliases.add("USDC", "polygon", "0x2...", "USD Coin");
334
335 let chains = aliases.get_chains_for_alias("USDC");
336 assert_eq!(chains.len(), 2);
337 assert!(chains.contains(&"ethereum"));
338 assert!(chains.contains(&"polygon"));
339 }
340
341 #[test]
342 fn test_get_chains_for_missing_alias() {
343 let aliases = TokenAliases::default();
344 let chains = aliases.get_chains_for_alias("NONEXISTENT");
345 assert!(chains.is_empty());
346 }
347
348 #[test]
349 fn test_is_address_solana() {
350 assert!(TokenAliases::is_address(
352 "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"
353 ));
354 assert!(TokenAliases::is_address("11111111111111111111111111111111"));
356 }
357
358 #[test]
359 fn test_is_address_tron() {
360 assert!(TokenAliases::is_address(
362 "TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"
363 ));
364 assert!(TokenAliases::is_address(
365 "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
366 ));
367 }
368
369 #[test]
370 fn test_is_address_edge_cases() {
371 assert!(!TokenAliases::is_address("")); assert!(!TokenAliases::is_address("0x")); assert!(!TokenAliases::is_address("T123")); assert!(!TokenAliases::is_address("hello world")); }
376
377 #[test]
378 fn test_remove_specific_chain() {
379 let mut aliases = TokenAliases::default();
380 aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
381 aliases.add("USDC", "polygon", "0x2...", "USD Coin");
382
383 aliases.remove("USDC", Some("polygon"));
385 assert!(aliases.get("USDC", Some("polygon")).is_none());
386 assert!(aliases.get("USDC", Some("ethereum")).is_some());
387 }
388
389 #[test]
390 fn test_remove_last_chain_cleans_up() {
391 let mut aliases = TokenAliases::default();
392 aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
393
394 aliases.remove("USDC", Some("ethereum"));
396 assert!(aliases.get("USDC", None).is_none());
397 let chains = aliases.get_chains_for_alias("USDC");
398 assert!(chains.is_empty());
399 }
400
401 #[test]
402 fn test_remove_cleans_recent() {
403 let mut aliases = TokenAliases::default();
404 aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
405 assert_eq!(aliases.recent().len(), 1);
406
407 aliases.remove("USDC", None);
408 assert!(aliases.recent().is_empty());
409 }
410
411 #[test]
412 fn test_add_updates_existing() {
413 let mut aliases = TokenAliases::default();
414 aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
415 aliases.add("USDC", "ethereum", "0x2...", "USD Coin V2");
416
417 let info = aliases.get("USDC", Some("ethereum")).unwrap();
418 assert_eq!(info.address, "0x2...");
419 assert_eq!(info.name, "USD Coin V2");
420 }
421
422 #[test]
423 fn test_recent_truncation() {
424 let mut aliases = TokenAliases::default();
425 for i in 0..25 {
427 aliases.add(
428 &format!("T{}", i),
429 "ethereum",
430 &format!("0x{}...", i),
431 &format!("Token {}", i),
432 );
433 }
434 assert_eq!(aliases.recent().len(), 20);
435 assert_eq!(aliases.recent()[0].symbol, "T24");
437 }
438
439 #[test]
440 fn test_case_insensitive_operations() {
441 let mut aliases = TokenAliases::default();
442 aliases.add("usdc", "Ethereum", "0x1...", "USD Coin");
443
444 let info = aliases.get("USDC", Some("ethereum")).unwrap();
446 assert_eq!(info.symbol, "USDC");
447 assert_eq!(info.chain, "ethereum");
448 }
449
450 #[test]
451 fn test_token_info_has_last_used() {
452 let mut aliases = TokenAliases::default();
453 aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
454 let info = aliases.get("USDC", Some("ethereum")).unwrap();
455 assert!(info.last_used.is_some());
456 }
457
458 #[test]
459 fn test_save_and_load_roundtrip() {
460 let mut aliases = TokenAliases::default();
461 aliases.add("SAVE_TEST", "ethereum", "0xsave...", "Save Test Token");
462
463 let result = aliases.save();
465 assert!(result.is_ok());
466
467 let loaded = TokenAliases::load();
469 let info = loaded.get("SAVE_TEST", Some("ethereum"));
470 assert!(info.is_some());
471 assert_eq!(info.unwrap().address, "0xsave...");
472
473 aliases.remove("SAVE_TEST", None);
475 let _ = aliases.save();
476 }
477}