1use std::{collections::HashMap, num::NonZeroUsize, path::PathBuf, sync::Arc, time::Duration};
2use tokio::time::sleep;
3
4use async_trait::async_trait;
5use log::error;
6use lru::LruCache;
7use reqwest::{Client, Error as ReqwestError};
8use serde::Deserialize;
9use tokio::{fs, sync::RwLock, task};
10
11const DEFAULT_CACHE_SIZE: usize = 5_000;
13
14pub type Selector = [u8; 4];
16
17#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct SigInfo {
20 pub text: String,
22 pub selector: Selector,
23}
24
25#[derive(thiserror::Error, Debug)]
27pub enum ResolveError {
28 #[error("network error")]
29 Network(#[from] ReqwestError),
30 #[error("io error")]
31 Io(#[from] std::io::Error),
32 #[error("json error")]
33 Json(#[from] serde_json::Error),
34 #[error("task join error")]
35 Join(#[from] task::JoinError),
36}
37
38#[async_trait]
40pub trait SelectorResolver: Send + Sync {
41 async fn resolve(&self, selector: Selector) -> Result<Arc<[SigInfo]>, ResolveError>;
42}
43
44pub struct NullResolver;
46
47#[async_trait]
48impl SelectorResolver for NullResolver {
49 async fn resolve(&self, _selector: Selector) -> Result<Arc<[SigInfo]>, ResolveError> {
50 Ok(Arc::from([]))
51 }
52}
53
54#[derive(Clone)]
56pub struct CompositeResolver {
57 cache: Arc<RwLock<LruCache<Selector, Arc<[SigInfo]>>>>,
58 cache_file: PathBuf,
59 client: Client,
60 base_url: String,
61}
62
63#[derive(Deserialize)]
64struct FourByteEntry {
65 text_signature: String,
66}
67
68#[derive(Deserialize)]
69struct FourByteResponse {
70 #[serde(rename = "results")]
71 results: Vec<FourByteEntry>,
72}
73
74impl CompositeResolver {
75 pub async fn new(cache_file: PathBuf, base_url: Option<String>) -> Self {
77 let map: HashMap<String, Vec<String>> = fs::read_to_string(&cache_file)
78 .await
79 .ok()
80 .and_then(|s| serde_json::from_str(&s).ok())
81 .unwrap_or_default();
82
83 let mut lru = LruCache::new(NonZeroUsize::new(DEFAULT_CACHE_SIZE).expect("non‑zero"));
84 for (hex_sel, sigs) in map {
85 if let Ok(bytes) = hex::decode(hex_sel) {
86 if bytes.len() == 4 {
87 let mut arr = [0u8; 4];
88 arr.copy_from_slice(&bytes);
89 let arc = Arc::from(
90 sigs.into_iter()
91 .map(|text| SigInfo {
92 text,
93 selector: arr,
94 })
95 .collect::<Vec<_>>(),
96 );
97 lru.put(arr, arc);
98 }
99 }
100 }
101
102 Self {
103 cache: Arc::new(RwLock::new(lru)),
104 cache_file,
105 client: Client::builder()
106 .user_agent("evm-lens (+https://github.com/andyrobert3/evm-lens)")
107 .build()
108 .expect("reqwest client"),
109 base_url: base_url.unwrap_or_else(|| "https://www.4byte.directory".to_string()),
110 }
111 }
112
113 async fn persist(&self) -> Result<(), ResolveError> {
115 let snapshot: HashMap<String, Vec<String>> = {
117 let cache = self.cache.read().await;
118 cache
119 .iter()
120 .map(|(sel, sigs)| {
121 let key = hex::encode(sel);
122 let val = sigs.iter().map(|s| s.text.clone()).collect();
123 (key, val)
124 })
125 .collect()
126 };
127
128 let tmp = self.cache_file.with_extension("json.tmp");
129 let json = serde_json::to_vec_pretty(&snapshot)?;
130
131 let tmp_clone = tmp.clone();
133 let cache_file = self.cache_file.clone();
134 task::spawn_blocking(move || {
135 std::fs::write(&tmp_clone, &json)?;
136 std::fs::rename(&tmp_clone, &cache_file)?;
137 Ok::<(), std::io::Error>(())
138 })
139 .await??;
140
141 Ok(())
142 }
143
144 async fn fetch_remote(&self, selector: Selector) -> Result<Arc<[SigInfo]>, ResolveError> {
147 let url = format!(
148 "{}/api/v1/signatures/?hex_signature=0x{}",
149 self.base_url,
150 hex::encode(selector)
151 );
152
153 let body = self
154 .client
155 .get(url)
156 .timeout(Duration::from_secs(5))
157 .send()
158 .await?
159 .error_for_status()?
160 .json::<FourByteResponse>()
161 .await?;
162
163 sleep(Duration::from_millis(200)).await;
165
166 let sigs: Vec<SigInfo> = body
167 .results
168 .into_iter()
169 .map(|e| SigInfo {
170 text: e.text_signature,
171 selector,
172 })
173 .collect();
174
175 Ok(Arc::from(sigs))
176 }
177}
178
179#[async_trait]
180impl SelectorResolver for CompositeResolver {
181 async fn resolve(&self, selector: Selector) -> Result<Arc<[SigInfo]>, ResolveError> {
182 if let Some(hit) = self.cache.read().await.peek(&selector).cloned() {
184 return Ok(hit);
185 }
186
187 let sigs = self.fetch_remote(selector).await?;
189
190 if !sigs.is_empty() {
191 self.cache.write().await.put(selector, sigs.clone());
192 let slf = self.clone();
194 tokio::spawn(async move {
195 if let Err(e) = slf.persist().await {
196 error!("persisting cache: {e}");
197 }
198 });
199 }
200
201 Ok(sigs)
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use mockito;
209 use serde_json::json;
210 use tempfile::tempdir;
211
212 #[tokio::test]
213 async fn test_null_resolver() {
214 let resolver = NullResolver;
215 let selector = [0xde, 0xad, 0xbe, 0xef];
216 let result = resolver.resolve(selector).await.unwrap();
217 assert!(result.is_empty());
218 }
219
220 #[tokio::test]
221 async fn test_composite_resolver_cache_miss_and_hit() {
222 let selector = [0xca, 0xfe, 0xba, 0xbe];
223 let sig_text = "cafeBabe()";
224 let mock_body = json!({
225 "count": 1,
226 "next": null,
227 "previous": null,
228 "results": [{ "text_signature": sig_text }]
229 });
230
231 let mut server = mockito::Server::new_async().await;
232 let mock_api = server
233 .mock("GET", "/api/v1/signatures/?hex_signature=0xcafebabe")
234 .with_status(200)
235 .with_header("content-type", "application/json")
236 .with_body(mock_body.to_string())
237 .create_async()
238 .await;
239
240 let temp_dir = tempdir().unwrap();
241 let cache_file = temp_dir.path().join("test_cache.json");
242
243 let resolver = CompositeResolver::new(cache_file, Some(server.url())).await;
244
245 let result = resolver.resolve(selector).await.unwrap();
247 assert_eq!(result.len(), 1);
248 assert_eq!(result[0].text, sig_text);
249 assert_eq!(result[0].selector, selector);
250 mock_api.assert_async().await;
251
252 let result_cached = resolver.resolve(selector).await.unwrap();
254 assert_eq!(result, result_cached);
255 mock_api.assert_async().await; }
257
258 #[tokio::test]
259 async fn test_composite_resolver_loads_from_disk() {
260 let selector = [0xfe, 0xed, 0xfa, 0xce];
261 let sig_text = "feedFace(uint256)";
262 let cache_content = json!({
263 "feedface": [sig_text]
264 });
265
266 let temp_dir = tempdir().unwrap();
267 let cache_file = temp_dir.path().join("preloaded_cache.json");
268 fs::write(&cache_file, cache_content.to_string())
269 .await
270 .unwrap();
271
272 let mut server = mockito::Server::new_async().await;
273 let mock_api = server
275 .mock("GET", "/api/v1/signatures/?hex_signature=0xfeedface")
276 .with_status(500) .expect(0)
278 .create_async()
279 .await;
280
281 let resolver = CompositeResolver::new(cache_file, Some(server.url())).await;
282 let result = resolver.resolve(selector).await.unwrap();
283
284 assert_eq!(result.len(), 1);
285 assert_eq!(result[0].text, sig_text);
286 mock_api.assert_async().await;
287 }
288
289 #[tokio::test]
290 async fn test_composite_resolver_network_error() {
291 let selector = [0xba, 0xad, 0xf0, 0x0d];
292
293 let mut server = mockito::Server::new_async().await;
294 server
295 .mock("GET", "/api/v1/signatures/?hex_signature=0xbaadf00d")
296 .with_status(500) .create_async()
298 .await;
299
300 let temp_dir = tempdir().unwrap();
301 let cache_file = temp_dir.path().join("error_cache.json");
302
303 let resolver = CompositeResolver::new(cache_file, Some(server.url())).await;
304
305 let result = resolver.resolve(selector).await;
306 assert!(matches!(result, Err(ResolveError::Network(_))));
307 }
308}