1use crate::error::{Result, ScopeError};
33use serde::{Deserialize, Serialize};
34use std::collections::HashMap;
35use std::path::PathBuf;
36
37pub const STABLECOIN_SYMBOLS: [&str; 10] = [
40 "USDC", "USDT", "DAI", "BUSD", "TUSD", "USDP", "FRAX", "LUSD", "GUSD", "FDUSD",
41];
42
43pub fn is_stablecoin_symbol(symbol: &str) -> bool {
46 let upper = symbol.to_uppercase();
47 STABLECOIN_SYMBOLS.contains(&upper.as_str())
48}
49
50const STABLECOIN_PRICE_RANGE: std::ops::RangeInclusive<f64> = 0.95..=1.05;
52
53pub fn is_stablecoin_price(price: f64) -> bool {
55 STABLECOIN_PRICE_RANGE.contains(&price)
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct TokenInfo {
61 pub address: String,
63
64 pub symbol: String,
66
67 pub name: String,
69
70 pub chain: String,
72
73 #[serde(default)]
75 pub last_used: Option<i64>,
76
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub venue: Option<String>,
80}
81
82#[derive(Debug, Clone, Default, Serialize, Deserialize)]
84pub struct TokenAliases {
85 #[serde(default)]
88 aliases: HashMap<String, HashMap<String, TokenInfo>>,
89
90 #[serde(default)]
92 recent: Vec<TokenInfo>,
93}
94
95impl TokenAliases {
96 pub fn aliases_path() -> Option<PathBuf> {
98 Some(crate::paths::data_dir().join("tokens.yaml"))
99 }
100
101 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 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 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 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 self.aliases
161 .entry(alias_key)
162 .or_default()
163 .insert(chain_key, info.clone());
164
165 self.recent
167 .retain(|t| !(t.symbol == info.symbol && t.chain == info.chain));
168 self.recent.insert(0, info);
169
170 self.recent.truncate(20);
172 }
173
174 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 chain_map
194 .get("ethereum")
195 .or_else(|| chain_map.values().next())
196 }
197 } else {
198 None
199 }
200 }
201
202 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 pub fn recent(&self) -> &[TokenInfo] {
213 &self.recent
214 }
215
216 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 pub fn list(&self) -> Vec<&TokenInfo> {
238 self.aliases
239 .values()
240 .flat_map(|chain_map| chain_map.values())
241 .collect()
242 }
243
244 pub fn is_address(input: &str) -> bool {
246 if input.starts_with("0x") && input.len() == 42 {
248 return input[2..].chars().all(|c| c.is_ascii_hexdigit());
249 }
250
251 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 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#[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 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 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 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 aliases.remove("USDC", None);
328 assert!(aliases.get("USDC", None).is_none());
329 }
330
331 #[test]
332 fn test_is_address() {
333 assert!(TokenAliases::is_address(
335 "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
336 ));
337 assert!(TokenAliases::is_address(
338 "0x0000000000000000000000000000000000000000"
339 ));
340
341 assert!(!TokenAliases::is_address("USDC"));
343 assert!(!TokenAliases::is_address("ethereum"));
344 assert!(!TokenAliases::is_address("0x123")); }
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 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 assert!(TokenAliases::is_address(
392 "DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"
393 ));
394 assert!(TokenAliases::is_address("11111111111111111111111111111111"));
396 }
397
398 #[test]
399 fn test_is_address_tron() {
400 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("")); assert!(!TokenAliases::is_address("0x")); assert!(!TokenAliases::is_address("T123")); assert!(!TokenAliases::is_address("hello world")); }
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 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 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 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 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 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 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 let result = aliases.save();
517 assert!(result.is_ok());
518
519 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 aliases.remove("SAVE_TEST", None);
527 let _ = aliases.save();
528 }
529}