1use std::collections::{HashMap, HashSet};
2use std::future::Future;
3use std::pin::Pin;
4
5use crate::error::ResolveError;
6use crate::types::descriptor::Descriptor;
7
8use super::source::{DescriptorSource, ResolvedDescriptor, TypedDescriptorLookup};
9
10pub struct GitHubRegistrySource {
14 base_url: String,
15 calldata_index: HashMap<String, String>,
17 eip712_index: HashMap<String, HashMap<String, Vec<Eip712IndexEntry>>>,
19 cache: tokio::sync::Mutex<HashMap<String, Descriptor>>,
21}
22
23#[derive(Debug, Clone, serde::Deserialize)]
24pub struct Eip712IndexEntry {
25 pub(crate) path: String,
26 #[serde(rename = "encodeTypeHashes", default)]
27 pub(crate) encode_type_hashes: Vec<String>,
28}
29
30impl GitHubRegistrySource {
31 pub fn new(
37 base_url: &str,
38 calldata_index: HashMap<String, String>,
39 eip712_index: HashMap<String, HashMap<String, Vec<Eip712IndexEntry>>>,
40 ) -> Self {
41 Self {
42 base_url: base_url.trim_end_matches('/').to_string(),
43 calldata_index,
44 eip712_index,
45 cache: tokio::sync::Mutex::new(HashMap::new()),
46 }
47 }
48
49 pub async fn from_registry(base_url: &str) -> Result<Self, ResolveError> {
51 let base = base_url.trim_end_matches('/');
52 let calldata_url = format!("{}/index.calldata.json", base);
53 let eip712_url = format!("{}/index.eip712.json", base);
54 let calldata_index = fetch_index::<HashMap<String, String>>(&calldata_url).await?;
55 let eip712_index =
56 fetch_index::<HashMap<String, HashMap<String, Vec<Eip712IndexEntry>>>>(&eip712_url)
57 .await?;
58
59 Ok(Self {
60 base_url: base.to_string(),
61 calldata_index,
62 eip712_index,
63 cache: tokio::sync::Mutex::new(HashMap::new()),
64 })
65 }
66
67 fn make_key(chain_id: u64, address: &str) -> String {
68 format!("eip155:{}:{}", chain_id, address.to_lowercase())
69 }
70
71 const MAX_INCLUDES_DEPTH: u8 = 3;
73
74 async fn fetch_raw(&self, rel_path: &str) -> Result<String, ResolveError> {
75 let url = format!("{}/{}", self.base_url, rel_path);
76 let response = reqwest::get(&url)
77 .await
78 .map_err(|e| ResolveError::RegistryIo(format!("HTTP fetch failed for {url}: {e}")))?;
79 if response.status() == reqwest::StatusCode::NOT_FOUND {
80 return Err(ResolveError::RegistryDescriptorMissing { url });
81 }
82 response
83 .text()
84 .await
85 .map_err(|e| ResolveError::RegistryIo(format!("read response for {url}: {e}")))
86 }
87
88 async fn fetch_descriptor(&self, rel_path: &str) -> Result<Descriptor, ResolveError> {
89 let value = self
90 .fetch_and_merge_value(rel_path, Self::MAX_INCLUDES_DEPTH)
91 .await?;
92 serde_json::from_value::<Descriptor>(value).map_err(|e| ResolveError::Parse(e.to_string()))
93 }
94
95 async fn fetch_descriptor_cached(&self, rel_path: &str) -> Result<Descriptor, ResolveError> {
97 {
98 let cache = self.cache.lock().await;
99 if let Some(cached) = cache.get(rel_path) {
100 return Ok(cached.clone());
101 }
102 }
103 let descriptor = self.fetch_descriptor(rel_path).await?;
104 self.cache
105 .lock()
106 .await
107 .insert(rel_path.to_string(), descriptor.clone());
108 Ok(descriptor)
109 }
110
111 fn fetch_and_merge_value<'a>(
116 &'a self,
117 rel_path: &'a str,
118 depth: u8,
119 ) -> Pin<Box<dyn Future<Output = Result<serde_json::Value, ResolveError>> + Send + 'a>> {
120 Box::pin(async move {
121 let body = self.fetch_raw(rel_path).await?;
122 let value: serde_json::Value =
123 serde_json::from_str(&body).map_err(|e| ResolveError::Parse(e.to_string()))?;
124
125 let includes = value
126 .as_object()
127 .and_then(|o| o.get("includes"))
128 .and_then(|v| v.as_str())
129 .map(String::from);
130
131 if let Some(includes_path) = includes {
132 if depth == 0 {
133 return Err(ResolveError::RegistryIo(
134 "max includes depth exceeded (possible circular reference)".to_string(),
135 ));
136 }
137
138 let resolved_path = resolve_relative_path(rel_path, &includes_path);
139 let included_value = self
140 .fetch_and_merge_value(&resolved_path, depth - 1)
141 .await?;
142
143 Ok(crate::merge::merge_descriptor_values(
144 &value,
145 &included_value,
146 ))
147 } else {
148 Ok(value)
149 }
150 })
151 }
152
153 async fn resolve_typed_candidates_inner(
154 &self,
155 lookup: &TypedDescriptorLookup,
156 ) -> Result<Vec<ResolvedDescriptor>, ResolveError> {
157 let address_lower = lookup.verifying_contract.to_lowercase();
158 let key = Self::make_key(lookup.chain_id, &address_lower);
159 let entries = self
160 .eip712_index
161 .get(&key)
162 .and_then(|bucket| bucket.get(&lookup.primary_type))
163 .ok_or_else(|| ResolveError::NotFound {
164 chain_id: lookup.chain_id,
165 address: address_lower.clone(),
166 })?;
167
168 let filtered_entries =
169 filter_typed_index_entries(entries, lookup.encode_type_hash.as_deref());
170 if filtered_entries.is_empty() {
171 return Err(ResolveError::NotFound {
172 chain_id: lookup.chain_id,
173 address: address_lower,
174 });
175 }
176
177 let mut seen_paths = HashSet::new();
178 let mut candidates = Vec::new();
179 for entry in filtered_entries {
180 if !seen_paths.insert(entry.path.as_str()) {
181 continue;
182 }
183 let descriptor = self.fetch_descriptor_cached(&entry.path).await?;
184 candidates.push(ResolvedDescriptor {
185 descriptor,
186 chain_id: lookup.chain_id,
187 address: lookup.verifying_contract.to_lowercase(),
188 });
189 }
190 Ok(candidates)
191 }
192}
193
194impl DescriptorSource for GitHubRegistrySource {
195 fn resolve_calldata(
196 &self,
197 chain_id: u64,
198 address: &str,
199 ) -> Pin<Box<dyn Future<Output = Result<ResolvedDescriptor, ResolveError>> + Send + '_>> {
200 let addr = address.to_lowercase();
201 Box::pin(async move {
202 let key = Self::make_key(chain_id, &addr);
203 let path = self
204 .calldata_index
205 .get(&key)
206 .ok_or_else(|| ResolveError::NotFound {
207 chain_id,
208 address: addr.clone(),
209 })?;
210 let descriptor = self.fetch_descriptor_cached(path).await?;
211 Ok(ResolvedDescriptor {
212 descriptor,
213 chain_id,
214 address: addr,
215 })
216 })
217 }
218
219 fn resolve_typed_candidates(
220 &self,
221 lookup: TypedDescriptorLookup,
222 ) -> Pin<Box<dyn Future<Output = Result<Vec<ResolvedDescriptor>, ResolveError>> + Send + '_>>
223 {
224 Box::pin(async move { self.resolve_typed_candidates_inner(&lookup).await })
225 }
226}
227
228async fn fetch_index<T: serde::de::DeserializeOwned>(url: &str) -> Result<T, ResolveError> {
229 let response = reqwest::get(url)
230 .await
231 .map_err(|e| ResolveError::RegistryIo(format!("HTTP fetch index failed for {url}: {e}")))?;
232 if response.status() == reqwest::StatusCode::NOT_FOUND {
233 return Err(ResolveError::RegistryIndexMissing {
234 url: url.to_string(),
235 });
236 }
237 let body = response
238 .text()
239 .await
240 .map_err(|e| ResolveError::RegistryIo(format!("read index response for {url}: {e}")))?;
241 serde_json::from_str(&body).map_err(|e| ResolveError::Parse(e.to_string()))
242}
243
244fn filter_typed_index_entries<'a>(
245 entries: &'a [Eip712IndexEntry],
246 expected_hash: Option<&str>,
247) -> Vec<&'a Eip712IndexEntry> {
248 match expected_hash {
249 Some(expected_hash) => entries
250 .iter()
251 .filter(|entry| {
252 entry
253 .encode_type_hashes
254 .iter()
255 .any(|hash| hash.eq_ignore_ascii_case(expected_hash))
256 })
257 .collect::<Vec<_>>(),
258 None => entries.iter().collect(),
259 }
260}
261
262fn resolve_relative_path(base: &str, relative: &str) -> String {
266 let relative = relative.strip_prefix("./").unwrap_or(relative);
267
268 let dir = if let Some(pos) = base.rfind('/') {
269 &base[..pos]
270 } else {
271 ""
272 };
273
274 if dir.is_empty() {
275 relative.to_string()
276 } else {
277 let mut parts: Vec<&str> = dir.split('/').collect();
278 let mut rel_remaining = relative;
279 while let Some(rest) = rel_remaining.strip_prefix("../") {
280 parts.pop();
281 rel_remaining = rest;
282 }
283 if parts.is_empty() {
284 rel_remaining.to_string()
285 } else {
286 format!("{}/{}", parts.join("/"), rel_remaining)
287 }
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use std::collections::HashMap;
294 use std::io::{Read, Write};
295 use std::net::TcpListener;
296 use std::thread;
297
298 use super::*;
299
300 fn spawn_test_server(
301 routes: Vec<(&'static str, u16, &'static str)>,
302 requests: usize,
303 ) -> (String, thread::JoinHandle<()>) {
304 let listener = TcpListener::bind("127.0.0.1:0").expect("bind test server");
305 let addr = listener.local_addr().expect("local addr");
306 let handle = thread::spawn(move || {
307 for _ in 0..requests {
308 let (mut stream, _) = listener.accept().expect("accept");
309 let mut request = Vec::new();
310 let mut buf = [0u8; 1024];
311 loop {
312 let n = stream.read(&mut buf).expect("read");
313 if n == 0 {
314 break;
315 }
316 request.extend_from_slice(&buf[..n]);
317 if request.windows(4).any(|window| window == b"\r\n\r\n") {
318 break;
319 }
320 }
321
322 let request = String::from_utf8_lossy(&request);
323 let path = request
324 .lines()
325 .next()
326 .and_then(|line| line.split_whitespace().nth(1))
327 .unwrap_or("/");
328 let (status, body) = routes
329 .iter()
330 .find(|(route, _, _)| *route == path)
331 .map(|(_, status, body)| (*status, *body))
332 .unwrap_or((404, ""));
333 let status_text = match status {
334 200 => "OK",
335 404 => "Not Found",
336 _ => "Error",
337 };
338 let response = format!(
339 "HTTP/1.1 {status} {status_text}\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{}",
340 body.len(),
341 body
342 );
343 stream
344 .write_all(response.as_bytes())
345 .expect("write response");
346 }
347 });
348 (format!("http://{}", addr), handle)
349 }
350
351 #[test]
352 fn test_resolve_relative_path_same_dir() {
353 assert_eq!(
354 resolve_relative_path("aave/calldata-lpv3.json", "./erc20.json"),
355 "aave/erc20.json"
356 );
357 }
358
359 #[test]
360 fn test_resolve_relative_path_parent_dir() {
361 assert_eq!(
362 resolve_relative_path("aave/v3/calldata.json", "../../ercs/erc20.json"),
363 "ercs/erc20.json"
364 );
365 }
366
367 #[test]
368 fn test_resolve_relative_path_no_dir() {
369 assert_eq!(
370 resolve_relative_path("file.json", "./other.json"),
371 "other.json"
372 );
373 }
374
375 #[test]
376 fn test_filter_typed_index_entries_requires_exact_hash_for_split_entries() {
377 let entries = vec![
378 Eip712IndexEntry {
379 path: "registry/a.json".to_string(),
380 encode_type_hashes: vec!["0xaaaa".to_string()],
381 },
382 Eip712IndexEntry {
383 path: "registry/legacy.json".to_string(),
384 encode_type_hashes: Vec::new(),
385 },
386 ];
387
388 let filtered = filter_typed_index_entries(&entries, Some("0xaaaa"));
389 assert_eq!(filtered.len(), 1);
390 assert_eq!(filtered[0].path, "registry/a.json");
391
392 let no_match = filter_typed_index_entries(&entries, Some("0xbbbb"));
393 assert!(no_match.is_empty());
394 }
395
396 #[test]
397 fn test_filter_typed_index_entries_rejects_empty_hash_entries() {
398 let entries = vec![
399 Eip712IndexEntry {
400 path: "registry/a.json".to_string(),
401 encode_type_hashes: Vec::new(),
402 },
403 Eip712IndexEntry {
404 path: "registry/b.json".to_string(),
405 encode_type_hashes: Vec::new(),
406 },
407 ];
408
409 let filtered = filter_typed_index_entries(&entries, Some("0xaaaa"));
410 assert!(filtered.is_empty());
411 }
412
413 #[tokio::test]
414 async fn test_from_registry_requires_split_indexes() {
415 let (base_url, handle) = spawn_test_server(
416 vec![
417 ("/index.calldata.json", 404, ""),
418 ("/index.eip712.json", 200, "{}"),
419 ],
420 1,
421 );
422
423 let err = match GitHubRegistrySource::from_registry(&base_url).await {
424 Ok(_) => panic!("missing split index should fail"),
425 Err(err) => err,
426 };
427 match err {
428 ResolveError::RegistryIndexMissing { url } => {
429 assert!(url.ends_with("/index.calldata.json"));
430 }
431 other => panic!("expected RegistryIndexMissing, got {other:?}"),
432 }
433
434 handle.join().expect("server join");
435 }
436
437 #[tokio::test]
438 async fn test_resolve_calldata_reports_missing_descriptor_file() {
439 let (base_url, handle) = spawn_test_server(vec![("/registry/missing.json", 404, "")], 1);
440
441 let source = GitHubRegistrySource::new(
442 &base_url,
443 HashMap::from([(
444 "eip155:1:0xabc".to_string(),
445 "registry/missing.json".to_string(),
446 )]),
447 HashMap::new(),
448 );
449
450 let err = source
451 .resolve_calldata(1, "0xabc")
452 .await
453 .expect_err("missing descriptor file should fail");
454 match err {
455 ResolveError::RegistryDescriptorMissing { url } => {
456 assert!(url.ends_with("/registry/missing.json"));
457 }
458 other => panic!("expected RegistryDescriptorMissing, got {other:?}"),
459 }
460
461 handle.join().expect("server join");
462 }
463}