use crate::error::{Result, ScopeError};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
pub const STABLECOIN_SYMBOLS: [&str; 10] = [
"USDC", "USDT", "DAI", "BUSD", "TUSD", "USDP", "FRAX", "LUSD", "GUSD", "FDUSD",
];
pub fn is_stablecoin_symbol(symbol: &str) -> bool {
let upper = symbol.to_uppercase();
STABLECOIN_SYMBOLS.contains(&upper.as_str())
}
const STABLECOIN_PRICE_RANGE: std::ops::RangeInclusive<f64> = 0.95..=1.05;
pub fn is_stablecoin_price(price: f64) -> bool {
STABLECOIN_PRICE_RANGE.contains(&price)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TokenInfo {
pub address: String,
pub symbol: String,
pub name: String,
pub chain: String,
#[serde(default)]
pub last_used: Option<i64>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TokenAliases {
#[serde(default)]
aliases: HashMap<String, HashMap<String, TokenInfo>>,
#[serde(default)]
recent: Vec<TokenInfo>,
}
impl TokenAliases {
pub fn aliases_path() -> Option<PathBuf> {
crate::paths::data_dir().map(|p| p.join("scope").join("tokens.yaml"))
}
pub fn load() -> Self {
Self::aliases_path()
.and_then(|path| std::fs::read_to_string(&path).ok())
.and_then(|contents| serde_yaml::from_str(&contents).ok())
.unwrap_or_default()
}
pub fn save(&self) -> Result<()> {
if let Some(path) = Self::aliases_path() {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
ScopeError::io_context(e, "Failed to create token aliases directory")
})?;
}
let contents = serde_yaml::to_string(self)
.map_err(|e| ScopeError::Export(format!("Failed to serialize aliases: {}", e)))?;
std::fs::write(&path, contents)
.map_err(|e| ScopeError::io_context(e, "Failed to write token aliases"))?;
}
Ok(())
}
pub fn add(&mut self, alias: &str, chain: &str, address: &str, name: &str) {
let alias_key = alias.to_uppercase();
let chain_key = chain.to_lowercase();
let info = TokenInfo {
address: address.to_string(),
symbol: alias.to_uppercase(),
name: name.to_string(),
chain: chain_key.clone(),
last_used: Some(chrono::Utc::now().timestamp()),
};
self.aliases
.entry(alias_key)
.or_default()
.insert(chain_key, info.clone());
self.recent
.retain(|t| !(t.symbol == info.symbol && t.chain == info.chain));
self.recent.insert(0, info);
self.recent.truncate(20);
}
pub fn get(&self, alias: &str, chain: Option<&str>) -> Option<&TokenInfo> {
let alias_key = alias.to_uppercase();
if let Some(chain_map) = self.aliases.get(&alias_key) {
if let Some(chain) = chain {
let chain_key = chain.to_lowercase();
chain_map.get(&chain_key)
} else {
chain_map
.get("ethereum")
.or_else(|| chain_map.values().next())
}
} else {
None
}
}
pub fn get_chains_for_alias(&self, alias: &str) -> Vec<&str> {
let alias_key = alias.to_uppercase();
self.aliases
.get(&alias_key)
.map(|chain_map| chain_map.keys().map(|s| s.as_str()).collect())
.unwrap_or_default()
}
pub fn recent(&self) -> &[TokenInfo] {
&self.recent
}
pub fn remove(&mut self, alias: &str, chain: Option<&str>) {
let alias_key = alias.to_uppercase();
if let Some(chain) = chain {
let chain_key = chain.to_lowercase();
if let Some(chain_map) = self.aliases.get_mut(&alias_key) {
chain_map.remove(&chain_key);
if chain_map.is_empty() {
self.aliases.remove(&alias_key);
}
}
self.recent
.retain(|t| !(t.symbol == alias_key && t.chain == chain_key));
} else {
self.aliases.remove(&alias_key);
self.recent.retain(|t| t.symbol != alias_key);
}
}
pub fn list(&self) -> Vec<&TokenInfo> {
self.aliases
.values()
.flat_map(|chain_map| chain_map.values())
.collect()
}
pub fn is_address(input: &str) -> bool {
if input.starts_with("0x") && input.len() == 42 {
return input[2..].chars().all(|c| c.is_ascii_hexdigit());
}
if input.len() >= 32
&& input.len() <= 44
&& let Ok(decoded) = bs58::decode(input).into_vec()
&& decoded.len() == 32
{
return true;
}
if input.starts_with('T') && input.len() == 34 {
return bs58::decode(input).into_vec().is_ok();
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_token_aliases_default() {
let aliases = TokenAliases::default();
assert!(aliases.aliases.is_empty());
assert!(aliases.recent.is_empty());
}
#[test]
fn test_add_and_get_alias() {
let mut aliases = TokenAliases::default();
aliases.add(
"USDC",
"ethereum",
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
"USD Coin",
);
let info = aliases.get("USDC", Some("ethereum")).unwrap();
assert_eq!(info.symbol, "USDC");
assert_eq!(info.chain, "ethereum");
assert!(info.address.starts_with("0x"));
let info2 = aliases.get("usdc", Some("ethereum")).unwrap();
assert_eq!(info2.symbol, "USDC");
}
#[test]
fn test_get_without_chain() {
let mut aliases = TokenAliases::default();
aliases.add("USDC", "ethereum", "0xETH...", "USD Coin");
aliases.add("USDC", "polygon", "0xPOLY...", "USD Coin");
let info = aliases.get("USDC", None).unwrap();
assert_eq!(info.chain, "ethereum");
}
#[test]
fn test_remove_alias() {
let mut aliases = TokenAliases::default();
aliases.add("USDC", "ethereum", "0x...", "USD Coin");
aliases.add("USDC", "polygon", "0x...", "USD Coin");
aliases.remove("USDC", Some("ethereum"));
assert!(aliases.get("USDC", Some("ethereum")).is_none());
assert!(aliases.get("USDC", Some("polygon")).is_some());
aliases.remove("USDC", None);
assert!(aliases.get("USDC", None).is_none());
}
#[test]
fn test_is_address() {
assert!(TokenAliases::is_address(
"0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"
));
assert!(TokenAliases::is_address(
"0x0000000000000000000000000000000000000000"
));
assert!(!TokenAliases::is_address("USDC"));
assert!(!TokenAliases::is_address("ethereum"));
assert!(!TokenAliases::is_address("0x123")); }
#[test]
fn test_recent_tokens() {
let mut aliases = TokenAliases::default();
aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
aliases.add("WETH", "ethereum", "0x2...", "Wrapped Ether");
assert_eq!(aliases.recent().len(), 2);
assert_eq!(aliases.recent()[0].symbol, "WETH");
}
#[test]
fn test_list_aliases() {
let mut aliases = TokenAliases::default();
aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
aliases.add("USDC", "polygon", "0x2...", "USD Coin");
aliases.add("WETH", "ethereum", "0x3...", "Wrapped Ether");
let list = aliases.list();
assert_eq!(list.len(), 3);
}
#[test]
fn test_get_chains_for_alias() {
let mut aliases = TokenAliases::default();
aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
aliases.add("USDC", "polygon", "0x2...", "USD Coin");
let chains = aliases.get_chains_for_alias("USDC");
assert_eq!(chains.len(), 2);
assert!(chains.contains(&"ethereum"));
assert!(chains.contains(&"polygon"));
}
#[test]
fn test_get_chains_for_missing_alias() {
let aliases = TokenAliases::default();
let chains = aliases.get_chains_for_alias("NONEXISTENT");
assert!(chains.is_empty());
}
#[test]
fn test_is_address_solana() {
assert!(TokenAliases::is_address(
"DRpbCBMxVnDK7maPM5tGv6MvB3v1sRMC86PZ8okm21hy"
));
assert!(TokenAliases::is_address("11111111111111111111111111111111"));
}
#[test]
fn test_is_address_tron() {
assert!(TokenAliases::is_address(
"TDqSquXBgUCLYvYC4XZgrprLK589dkhSCf"
));
assert!(TokenAliases::is_address(
"TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t"
));
}
#[test]
fn test_is_address_edge_cases() {
assert!(!TokenAliases::is_address("")); assert!(!TokenAliases::is_address("0x")); assert!(!TokenAliases::is_address("T123")); assert!(!TokenAliases::is_address("hello world")); }
#[test]
fn test_remove_specific_chain() {
let mut aliases = TokenAliases::default();
aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
aliases.add("USDC", "polygon", "0x2...", "USD Coin");
aliases.remove("USDC", Some("polygon"));
assert!(aliases.get("USDC", Some("polygon")).is_none());
assert!(aliases.get("USDC", Some("ethereum")).is_some());
}
#[test]
fn test_remove_last_chain_cleans_up() {
let mut aliases = TokenAliases::default();
aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
aliases.remove("USDC", Some("ethereum"));
assert!(aliases.get("USDC", None).is_none());
let chains = aliases.get_chains_for_alias("USDC");
assert!(chains.is_empty());
}
#[test]
fn test_remove_cleans_recent() {
let mut aliases = TokenAliases::default();
aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
assert_eq!(aliases.recent().len(), 1);
aliases.remove("USDC", None);
assert!(aliases.recent().is_empty());
}
#[test]
fn test_add_updates_existing() {
let mut aliases = TokenAliases::default();
aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
aliases.add("USDC", "ethereum", "0x2...", "USD Coin V2");
let info = aliases.get("USDC", Some("ethereum")).unwrap();
assert_eq!(info.address, "0x2...");
assert_eq!(info.name, "USD Coin V2");
}
#[test]
fn test_recent_truncation() {
let mut aliases = TokenAliases::default();
for i in 0..25 {
aliases.add(
&format!("T{}", i),
"ethereum",
&format!("0x{}...", i),
&format!("Token {}", i),
);
}
assert_eq!(aliases.recent().len(), 20);
assert_eq!(aliases.recent()[0].symbol, "T24");
}
#[test]
fn test_case_insensitive_operations() {
let mut aliases = TokenAliases::default();
aliases.add("usdc", "Ethereum", "0x1...", "USD Coin");
let info = aliases.get("USDC", Some("ethereum")).unwrap();
assert_eq!(info.symbol, "USDC");
assert_eq!(info.chain, "ethereum");
}
#[test]
fn test_token_info_has_last_used() {
let mut aliases = TokenAliases::default();
aliases.add("USDC", "ethereum", "0x1...", "USD Coin");
let info = aliases.get("USDC", Some("ethereum")).unwrap();
assert!(info.last_used.is_some());
}
#[test]
fn test_save_and_load_roundtrip() {
let mut aliases = TokenAliases::default();
aliases.add("SAVE_TEST", "ethereum", "0xsave...", "Save Test Token");
let result = aliases.save();
assert!(result.is_ok());
let loaded = TokenAliases::load();
let info = loaded.get("SAVE_TEST", Some("ethereum"));
assert!(info.is_some());
assert_eq!(info.unwrap().address, "0xsave...");
aliases.remove("SAVE_TEST", None);
let _ = aliases.save();
}
}