Skip to main content

clear_signing/resolver/
github_registry.rs

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
10/// HTTP-based descriptor source that fetches from a GitHub registry.
11///
12/// Requires the `github-registry` feature.
13pub struct GitHubRegistrySource {
14    base_url: String,
15    /// Calldata index: `"eip155:{chainId}:{address}"` → single relative path.
16    calldata_index: HashMap<String, String>,
17    /// EIP-712 index: `"eip155:{chainId}:{address}"` → `primaryType` buckets.
18    eip712_index: HashMap<String, HashMap<String, Vec<Eip712IndexEntry>>>,
19    /// In-memory descriptor cache keyed by relative path (tokio Mutex for async safety).
20    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    /// Create a new source with manually provided indexes.
32    ///
33    /// `base_url`: raw content URL prefix (e.g., `"https://raw.githubusercontent.com/org/repo/main"`).
34    /// `calldata_index`: maps `"eip155:{chainId}:{address}"` → single relative path.
35    /// `eip712_index`: maps `"eip155:{chainId}:{address}"` → list of `Eip712IndexEntry`.
36    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    /// Create a source by fetching the required split V3 index files from the registry.
50    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    /// Maximum depth for nested `includes` resolution.
72    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    /// Fetch a descriptor, checking the cache first.
96    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    /// Fetch a descriptor JSON and recursively resolve `includes`, returning
112    /// the merged JSON value. Deserialization into [`Descriptor`] happens only
113    /// at the top-level caller so that partial included files (which may lack
114    /// required fields like `context`) don't cause parse errors.
115    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
262/// Resolve a relative path against a base file path.
263///
264/// E.g., `resolve_relative_path("aave/calldata-lpv3.json", "./erc20.json")` → `"aave/erc20.json"`.
265fn 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}