eigen_types/
operator_metadata.rs

1use alloy::transports::http::reqwest::{self, Url};
2use mime_sniffer::MimeTypeSniffer;
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use std::{path::PathBuf, sync::OnceLock};
6use thiserror::Error;
7
8/// OperatorMetadata is the metadata operator uploads while registering
9/// itself to EigenLayer.
10#[derive(Deserialize, Serialize)]
11pub struct OperatorMetadata {
12    /// Name of the operator
13    name: String,
14
15    /// Description of the operator. There is a 200-character limit
16    description: String,
17
18    /// Logo of the operator. This should be a link to a image file
19    /// which is publicly accessible
20    logo: String,
21
22    /// Website of the operator
23    website: Option<String>,
24
25    /// Twitter handle of the operator (optional)
26    twitter: Option<String>,
27}
28
29#[derive(Debug, Error, PartialEq, Eq, Clone, Copy)]
30pub enum OperatorMetadataError {
31    #[error("Name cannot be empty")]
32    NameEmpty,
33    #[error("Name has to be less than 500 characters long")]
34    NameTooLong,
35    #[error("Name contains invalid characters")]
36    NameInvalid,
37
38    #[error("Description cannot be empty")]
39    DescriptionEmpty,
40    #[error("Description has to be less than 500 characters long")]
41    DescriptionTooLong,
42    #[error("Description contains invalid characters")]
43    DescriptionInvalid,
44
45    #[error("Logo cannot be empty")]
46    LogoUrlEmpty,
47    #[error("Logo url is invalid")]
48    LogoUrlInvalid,
49    #[error("Failed to fetch logo")]
50    LogoFetchFailed,
51    #[error("Logo url has an invalid image extension")]
52    LogoUrlInvalidImageExtension,
53    #[error("Logo has an unsupported mime type")]
54    LogoUrlInvalidMimeType,
55
56    #[error("Website url is invalid")]
57    WebsiteUrlInvalid,
58    #[error("Website url is longer than 1024 characters")]
59    WebsiteUrlTooLong,
60    #[error("Website url points to local server")]
61    WebsiteUrlPointsToLocalServer,
62
63    #[error("Twitter url is invalid. It must be of the format https://twitter.com/<username> or https://x.com/<username>")]
64    TwitterUrlInvalid,
65    #[error("Twitter url is longer than 1024 characters")]
66    TwitterUrlTooLong,
67    #[error("Twitter url points to local server")]
68    TwitterUrlPointsToLocalServer,
69}
70
71impl OperatorMetadata {
72    pub async fn validate(&self) -> Result<(), OperatorMetadataError> {
73        // Alias the error types for brevity
74        use OperatorMetadataError::*;
75
76        // name must be non-empty, less than 500 characters, and match the regex
77        if self.name.is_empty() {
78            return Err(NameEmpty);
79        }
80        if self.name.len() > 500 {
81            return Err(NameTooLong);
82        }
83        if !is_valid_text(&self.name) {
84            return Err(NameInvalid);
85        }
86
87        // description must be non-empty, no more than 500 characters, and match the regex
88        if self.description.is_empty() {
89            return Err(DescriptionEmpty);
90        }
91        if self.description.len() > 500 {
92            return Err(DescriptionTooLong);
93        }
94        if !is_valid_text(&self.description) {
95            return Err(DescriptionInvalid);
96        }
97
98        // logo must be non-empty, must be a valid URL, end in .png,
99        // and the server must return the content with a "image/png" mime type
100        if self.logo.is_empty() {
101            return Err(LogoUrlEmpty);
102        }
103        let Ok(url) = Url::parse(&self.logo) else {
104            return Err(LogoUrlInvalid);
105        };
106        let path = PathBuf::from(url.path());
107        if path.extension().map(|ext| ext != "png").unwrap_or(true) {
108            return Err(LogoUrlInvalidImageExtension);
109        }
110        // Check the server returns content with a "image/png" mime type
111        let response = reqwest::get(&self.logo).await.ok().ok_or(LogoFetchFailed)?;
112        let body = response.bytes().await.ok().ok_or(LogoFetchFailed)?;
113
114        if body.sniff_mime_type() != Some("image/png") {
115            return Err(LogoUrlInvalidMimeType);
116        }
117
118        // website, if non-empty, must have no more than 1024 characters,
119        // not point to localhost or 127.0.0.1, and must be a valid URL that matches the regex
120        if self.website.as_ref().is_some_and(|s| !s.is_empty()) {
121            let website = self.website.as_ref().unwrap();
122            if website.len() > 1024 {
123                return Err(WebsiteUrlTooLong);
124            }
125            let url = Url::parse(website).ok().ok_or(WebsiteUrlInvalid)?;
126
127            let host = url.host_str().ok_or(WebsiteUrlInvalid)?;
128            if url.scheme().is_empty() || host.is_empty() {
129                return Err(WebsiteUrlInvalid);
130            }
131            if host == "localhost" || host == "127.0.0.1" {
132                return Err(WebsiteUrlPointsToLocalServer);
133            }
134            if !is_website_url(website) {
135                return Err(WebsiteUrlInvalid);
136            }
137        }
138
139        // twitter, if non-empty, must no more than 1024 characters,
140        // not point to localhost or 127.0.0.1, and must be a valid URL that matches the regex
141        if self.twitter.as_ref().is_some_and(|s| !s.is_empty()) {
142            let twitter = self.twitter.as_ref().unwrap();
143            if twitter.len() > 1024 {
144                return Err(TwitterUrlTooLong);
145            }
146            let url = Url::parse(twitter).ok().ok_or(TwitterUrlInvalid)?;
147
148            let host = url.host_str().ok_or(TwitterUrlInvalid)?;
149            if url.scheme().is_empty() || host.is_empty() {
150                return Err(TwitterUrlInvalid);
151            }
152            if host == "localhost" || host == "127.0.0.1" {
153                return Err(TwitterUrlPointsToLocalServer);
154            }
155            if !is_twitter_url(twitter) {
156                return Err(TwitterUrlInvalid);
157            }
158        }
159
160        Ok(())
161    }
162}
163
164fn is_valid_text(text: &str) -> bool {
165    static REGEX: OnceLock<Regex> = OnceLock::new();
166    let regex = REGEX.get_or_init(|| {
167        regex::Regex::new(r#"^[a-zA-Z0-9 +.,;:?!'’"“”\-_/()\[\]~&#$—%]+$"#).expect("regex is valid")
168    });
169    regex.is_match(text)
170}
171
172fn is_website_url(website_url: &str) -> bool {
173    static REGEX: OnceLock<Regex> = OnceLock::new();
174    let regex = REGEX.get_or_init(|| {
175        regex::Regex::new(r#"^(https?)://[^\s/$.?#].[^\s]*$"#).expect("regex is valid")
176    });
177    regex.is_match(website_url)
178}
179
180fn is_twitter_url(twitter_url: &str) -> bool {
181    static REGEX: OnceLock<Regex> = OnceLock::new();
182    let regex = REGEX.get_or_init(|| {
183        regex::Regex::new(r#"^(?:https?://)?(?:www\.)?(?:twitter\.com/\w+|x\.com/\w+)(?:/?|$)"#)
184            .expect("regex is valid")
185    });
186
187    regex.is_match(twitter_url)
188}
189
190#[cfg(test)]
191mod tests {
192    use crate::{
193        operator::OperatorMetadata,
194        operator_metadata::{is_valid_text, is_website_url, OperatorMetadataError},
195    };
196
197    fn get_default_metadata() -> OperatorMetadata {
198        OperatorMetadata {
199            name: "Ethereum Utopia".to_string(),
200            description: "Rust operator is good operator".to_string(),
201            logo: "https://goerli-operator-metadata.s3.amazonaws.com/eigenlayer.png".to_string(),
202            website: Some("https://test.com".to_string()),
203            twitter: Some("https://twitter.com/test".to_string()),
204        }
205    }
206
207    #[tokio::test]
208    async fn test_is_valid_text() {
209        assert!(is_valid_text("this is some text"));
210        assert!(!is_valid_text("<>"));
211    }
212
213    #[tokio::test]
214    async fn test_is_website_url() {
215        assert!(is_website_url("https://test.com"));
216        assert!(!is_website_url("nothing"));
217    }
218
219    #[tokio::test]
220    async fn test_is_twitter_url() {
221        assert!(is_website_url("https://twitter.com/test"));
222        assert!(is_website_url("https://x.com/test"));
223        assert!(!is_website_url("nothing"));
224    }
225
226    // OperatorMetadata::validate
227
228    #[tokio::test]
229    async fn test_valid_metadata() {
230        let metadata = get_default_metadata();
231        metadata.validate().await.unwrap();
232    }
233
234    #[tokio::test]
235    async fn test_twitter_url_with_ending_slash() {
236        let mut metadata = get_default_metadata();
237        metadata.twitter = Some("https://twitter.com/test/".to_string());
238        metadata.validate().await.unwrap();
239    }
240
241    #[tokio::test]
242    async fn test_twitter_x_url() {
243        let mut metadata = get_default_metadata();
244        metadata.twitter = Some("https://x.com/test".to_string());
245        metadata.validate().await.unwrap();
246    }
247
248    #[tokio::test]
249    async fn test_empty_website_and_twitter() {
250        let mut metadata = get_default_metadata();
251        metadata.website = None;
252        metadata.twitter = None;
253        metadata.validate().await.unwrap();
254    }
255
256    #[tokio::test]
257    async fn test_invalid_no_name() {
258        let mut metadata = get_default_metadata();
259        metadata.name = "".to_string();
260        let err = metadata.validate().await.unwrap_err();
261        assert_eq!(err, OperatorMetadataError::NameEmpty);
262    }
263
264    #[tokio::test]
265    async fn test_invalid_name_too_long() {
266        let mut metadata = get_default_metadata();
267        metadata.name = "0".repeat(501);
268        let err = metadata.validate().await.unwrap_err();
269        assert_eq!(err, OperatorMetadataError::NameTooLong);
270    }
271
272    #[tokio::test]
273    async fn test_invalid_name_has_js_script() {
274        let mut metadata = get_default_metadata();
275        metadata.name = "<script> alert('test') </script>".to_string();
276        let err = metadata.validate().await.unwrap_err();
277        assert_eq!(err, OperatorMetadataError::NameInvalid);
278    }
279
280    #[tokio::test]
281    async fn test_invalid_no_description() {
282        let mut metadata = get_default_metadata();
283        metadata.description = "".to_string();
284        let err = metadata.validate().await.unwrap_err();
285        assert_eq!(err, OperatorMetadataError::DescriptionEmpty);
286    }
287
288    #[tokio::test]
289    async fn test_invalid_description_too_long() {
290        let mut metadata = get_default_metadata();
291        metadata.description = "0".repeat(501);
292        let err = metadata.validate().await.unwrap_err();
293        assert_eq!(err, OperatorMetadataError::DescriptionTooLong);
294    }
295
296    #[tokio::test]
297    async fn test_invalid_description_has_js_script() {
298        let mut metadata = get_default_metadata();
299        metadata.description = "<script> alert('test') </script>".to_string();
300        let err = metadata.validate().await.unwrap_err();
301        assert_eq!(err, OperatorMetadataError::DescriptionInvalid);
302    }
303
304    #[tokio::test]
305    async fn test_invalid_logo_url_empty() {
306        let mut metadata = get_default_metadata();
307        metadata.logo = "".to_string();
308        let err = metadata.validate().await.unwrap_err();
309        assert_eq!(err, OperatorMetadataError::LogoUrlEmpty);
310    }
311
312    #[tokio::test]
313    async fn test_invalid_logo_wrong_image_format() {
314        let mut metadata = get_default_metadata();
315        metadata.logo = "https://test.com/test.svg".to_string();
316        let err = metadata.validate().await.unwrap_err();
317        assert_eq!(err, OperatorMetadataError::LogoUrlInvalidImageExtension);
318    }
319
320    #[tokio::test]
321    async fn test_invalid_logo_invalid_mime_type() {
322        let mut metadata = get_default_metadata();
323        metadata.logo = "https://goerli-operator-metadata.s3.amazonaws.com/cat.png".to_string();
324        let err = metadata.validate().await.unwrap_err();
325        assert_eq!(err, OperatorMetadataError::LogoUrlInvalidMimeType);
326    }
327
328    #[tokio::test]
329    async fn test_invalid_website_url_1() {
330        let mut metadata = get_default_metadata();
331        metadata.website = Some("https".to_string());
332        let err = metadata.validate().await.unwrap_err();
333        assert_eq!(err, OperatorMetadataError::WebsiteUrlInvalid);
334    }
335
336    #[tokio::test]
337    async fn test_invalid_website_url_2() {
338        let mut metadata = get_default_metadata();
339        metadata.website = Some("https:/test.com".to_string());
340        let err = metadata.validate().await.unwrap_err();
341        assert_eq!(err, OperatorMetadataError::WebsiteUrlInvalid);
342    }
343
344    #[tokio::test]
345    async fn test_invalid_website_url_3() {
346        let mut metadata = get_default_metadata();
347        metadata.website = Some("ps://test.com".to_string());
348        let err = metadata.validate().await.unwrap_err();
349        assert_eq!(err, OperatorMetadataError::WebsiteUrlInvalid);
350    }
351
352    #[tokio::test]
353    async fn test_invalid_twitter_url_1() {
354        let mut metadata = get_default_metadata();
355        metadata.twitter = Some("http".to_string());
356        let err = metadata.validate().await.unwrap_err();
357        assert_eq!(err, OperatorMetadataError::TwitterUrlInvalid);
358    }
359
360    #[tokio::test]
361    async fn test_invalid_twitter_url_2() {
362        let mut metadata = get_default_metadata();
363        metadata.twitter = Some("ht://twitter.com/test".to_string());
364        let err = metadata.validate().await.unwrap_err();
365        assert_eq!(err, OperatorMetadataError::TwitterUrlInvalid);
366    }
367
368    #[tokio::test]
369    async fn test_invalid_twitter_url_3() {
370        let mut metadata = get_default_metadata();
371        metadata.twitter = Some("https:/twitt".to_string());
372        let err = metadata.validate().await.unwrap_err();
373        assert_eq!(err, OperatorMetadataError::TwitterUrlInvalid);
374    }
375
376    #[tokio::test]
377    async fn test_invalid_twitter_url_4() {
378        let mut metadata = get_default_metadata();
379        metadata.twitter = Some("https://facebook.com/test".to_string());
380        let err = metadata.validate().await.unwrap_err();
381        assert_eq!(err, OperatorMetadataError::TwitterUrlInvalid);
382    }
383}