Skip to main content

clear_signing/
token.rs

1//! Token metadata types and built-in providers.
2//! Uses CAIP-19 keys (`eip155:{chain}/erc20:{addr}`) for cross-chain lookups.
3
4use std::future::Future;
5use std::pin::Pin;
6
7use crate::provider::DataProvider;
8
9/// Token metadata.
10#[derive(Debug, Clone)]
11pub struct TokenMeta {
12    pub symbol: String,
13    pub decimals: u8,
14    pub name: String,
15}
16
17/// Normalized token lookup key (CAIP-19 style: `eip155:{chain_id}/erc20:{address}`).
18#[derive(Debug, Clone, PartialEq, Eq, Hash)]
19pub struct TokenLookupKey(pub String);
20
21impl TokenLookupKey {
22    /// Create a lookup key from chain ID and address.
23    pub fn new(chain_id: u64, address: &str) -> Self {
24        let addr = address.to_lowercase();
25        Self(format!("eip155:{chain_id}/erc20:{addr}"))
26    }
27}
28
29/// Well-known token source with embedded metadata for common tokens.
30pub struct WellKnownTokenSource {
31    tokens: std::collections::HashMap<TokenLookupKey, TokenMeta>,
32}
33
34impl WellKnownTokenSource {
35    pub fn new() -> Self {
36        let json_str = include_str!("assets/tokens.json");
37        let raw: std::collections::HashMap<String, WellKnownEntry> =
38            serde_json::from_str(json_str).expect("embedded tokens.json is valid");
39        let mut tokens = std::collections::HashMap::new();
40        for (key, entry) in raw {
41            tokens.insert(
42                TokenLookupKey(key),
43                TokenMeta {
44                    symbol: entry.symbol,
45                    decimals: entry.decimals,
46                    name: entry.name,
47                },
48            );
49        }
50        Self { tokens }
51    }
52}
53
54impl Default for WellKnownTokenSource {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60impl DataProvider for WellKnownTokenSource {
61    fn resolve_token(
62        &self,
63        chain_id: u64,
64        address: &str,
65    ) -> Pin<Box<dyn Future<Output = Option<TokenMeta>> + Send + '_>> {
66        let key = TokenLookupKey::new(chain_id, address);
67        let result = self.tokens.get(&key).cloned();
68        Box::pin(async move { result })
69    }
70}
71
72#[derive(serde::Deserialize)]
73struct WellKnownEntry {
74    symbol: String,
75    decimals: u8,
76    name: String,
77}
78
79/// Composite data provider that chains multiple providers, returning the first match.
80pub struct CompositeDataProvider {
81    providers: Vec<Box<dyn DataProvider>>,
82}
83
84impl CompositeDataProvider {
85    pub fn new(providers: Vec<Box<dyn DataProvider>>) -> Self {
86        Self { providers }
87    }
88}
89
90impl DataProvider for CompositeDataProvider {
91    fn resolve_token(
92        &self,
93        chain_id: u64,
94        address: &str,
95    ) -> Pin<Box<dyn Future<Output = Option<TokenMeta>> + Send + '_>> {
96        let address = address.to_string();
97        Box::pin(async move {
98            for provider in &self.providers {
99                if let Some(meta) = provider.resolve_token(chain_id, &address).await {
100                    return Some(meta);
101                }
102            }
103            None
104        })
105    }
106
107    fn resolve_ens_name(
108        &self,
109        address: &str,
110        chain_id: u64,
111        types: Option<&[String]>,
112    ) -> Pin<Box<dyn Future<Output = Option<String>> + Send + '_>> {
113        let address = address.to_string();
114        let types_owned: Option<Vec<String>> = types.map(|t| t.to_vec());
115        Box::pin(async move {
116            for provider in &self.providers {
117                if let Some(name) = provider
118                    .resolve_ens_name(&address, chain_id, types_owned.as_deref())
119                    .await
120                {
121                    return Some(name);
122                }
123            }
124            None
125        })
126    }
127
128    fn resolve_local_name(
129        &self,
130        address: &str,
131        chain_id: u64,
132        types: Option<&[String]>,
133    ) -> Pin<Box<dyn Future<Output = Option<String>> + Send + '_>> {
134        let address = address.to_string();
135        let types_owned: Option<Vec<String>> = types.map(|t| t.to_vec());
136        Box::pin(async move {
137            for provider in &self.providers {
138                if let Some(name) = provider
139                    .resolve_local_name(&address, chain_id, types_owned.as_deref())
140                    .await
141                {
142                    return Some(name);
143                }
144            }
145            None
146        })
147    }
148
149    fn resolve_nft_collection_name(
150        &self,
151        collection_address: &str,
152        chain_id: u64,
153    ) -> Pin<Box<dyn Future<Output = Option<String>> + Send + '_>> {
154        let collection_address = collection_address.to_string();
155        Box::pin(async move {
156            for provider in &self.providers {
157                if let Some(name) = provider
158                    .resolve_nft_collection_name(&collection_address, chain_id)
159                    .await
160                {
161                    return Some(name);
162                }
163            }
164            None
165        })
166    }
167}
168
169/// In-memory token source for testing.
170pub struct StaticTokenSource {
171    tokens: std::collections::HashMap<TokenLookupKey, TokenMeta>,
172}
173
174impl StaticTokenSource {
175    pub fn new() -> Self {
176        Self {
177            tokens: std::collections::HashMap::new(),
178        }
179    }
180
181    pub fn insert(&mut self, chain_id: u64, address: &str, meta: TokenMeta) {
182        self.tokens
183            .insert(TokenLookupKey::new(chain_id, address), meta);
184    }
185}
186
187impl Default for StaticTokenSource {
188    fn default() -> Self {
189        Self::new()
190    }
191}
192
193impl DataProvider for StaticTokenSource {
194    fn resolve_token(
195        &self,
196        chain_id: u64,
197        address: &str,
198    ) -> Pin<Box<dyn Future<Output = Option<TokenMeta>> + Send + '_>> {
199        let key = TokenLookupKey::new(chain_id, address);
200        let result = self.tokens.get(&key).cloned();
201        Box::pin(async move { result })
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    #[tokio::test]
210    async fn test_well_known_usdc_mainnet() {
211        let source = WellKnownTokenSource::new();
212        let meta = source
213            .resolve_token(1, "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
214            .await
215            .expect("USDC should be in well-known tokens");
216        assert_eq!(meta.symbol, "USDC");
217        assert_eq!(meta.decimals, 6);
218    }
219
220    #[tokio::test]
221    async fn test_well_known_usdc_base() {
222        let source = WellKnownTokenSource::new();
223        let meta = source
224            .resolve_token(8453, "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913")
225            .await
226            .expect("USDC on Base should be found");
227        assert_eq!(meta.symbol, "USDC");
228        assert_eq!(meta.decimals, 6);
229    }
230
231    #[tokio::test]
232    async fn test_well_known_not_found() {
233        let source = WellKnownTokenSource::new();
234        assert!(source
235            .resolve_token(1, "0x0000000000000000000000000000000000000001")
236            .await
237            .is_none());
238    }
239
240    #[tokio::test]
241    async fn test_composite_source_fallthrough() {
242        let mut custom = StaticTokenSource::new();
243        custom.insert(
244            1,
245            "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48",
246            TokenMeta {
247                symbol: "CUSTOM_USDC".to_string(),
248                decimals: 6,
249                name: "Custom USDC".to_string(),
250            },
251        );
252
253        let composite = CompositeDataProvider::new(vec![
254            Box::new(custom),
255            Box::new(WellKnownTokenSource::new()),
256        ]);
257
258        // Custom takes precedence
259        let meta = composite
260            .resolve_token(1, "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48")
261            .await
262            .unwrap();
263        assert_eq!(meta.symbol, "CUSTOM_USDC");
264
265        // Falls through to well-known for tokens not in custom
266        let meta2 = composite
267            .resolve_token(1, "0xdac17f958d2ee523a2206206994597c13d831ec7")
268            .await
269            .unwrap();
270        assert_eq!(meta2.symbol, "USDT");
271    }
272}