Skip to main content

foundry_common/tempo/
tip20.rs

1//! Shared TIP-20 helpers.
2
3use std::fmt;
4
5pub const TIP20_MAX_LOGO_URI_BYTES: usize = 256;
6pub const TIP20_ALLOWED_LOGO_URI_SCHEMES: &[&str] = &["https", "http", "ipfs", "data"];
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum Tip20LogoUriValidationError {
10    LogoURITooLong,
11    InvalidLogoURI,
12}
13
14impl fmt::Display for Tip20LogoUriValidationError {
15    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16        match self {
17            Self::LogoURITooLong => f.write_str("LogoURITooLong"),
18            Self::InvalidLogoURI => f.write_str("InvalidLogoURI"),
19        }
20    }
21}
22
23impl std::error::Error for Tip20LogoUriValidationError {}
24
25pub fn validate_tip20_logo_uri(uri: &str) -> Result<(), Tip20LogoUriValidationError> {
26    if uri.len() > TIP20_MAX_LOGO_URI_BYTES {
27        return Err(Tip20LogoUriValidationError::LogoURITooLong);
28    }
29
30    if uri.is_empty() {
31        return Ok(());
32    }
33
34    let Some((scheme, _)) = uri.split_once(':') else {
35        return Err(Tip20LogoUriValidationError::InvalidLogoURI);
36    };
37
38    let mut bytes = scheme.bytes();
39    if !bytes.next().is_some_and(|b| b.is_ascii_alphabetic())
40        || !bytes.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'))
41        || !TIP20_ALLOWED_LOGO_URI_SCHEMES
42            .iter()
43            .any(|allowed| scheme.eq_ignore_ascii_case(allowed))
44    {
45        return Err(Tip20LogoUriValidationError::InvalidLogoURI);
46    }
47
48    Ok(())
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54
55    #[test]
56    fn validates_empty_and_allowed_schemes_case_insensitively() {
57        for uri in [
58            "",
59            "https://example.com/logo.png",
60            "HTTP://example.com/logo.png",
61            "ipfs://bafybeigdyrzt",
62            "DATA:image/png;base64,abcd",
63        ] {
64            validate_tip20_logo_uri(uri).unwrap();
65        }
66    }
67
68    #[test]
69    fn rejects_invalid_schemes_and_overlong_values() {
70        for uri in [
71            "ftp://example.com/logo.png",
72            "1https://example.com/logo.png",
73            "https+foo://example.com/logo.png",
74            "example.com/logo.png",
75        ] {
76            assert_eq!(
77                validate_tip20_logo_uri(uri).unwrap_err(),
78                Tip20LogoUriValidationError::InvalidLogoURI
79            );
80        }
81
82        assert_eq!(
83            validate_tip20_logo_uri(&format!("https://{}", "a".repeat(249))).unwrap_err(),
84            Tip20LogoUriValidationError::LogoURITooLong
85        );
86    }
87
88    #[test]
89    fn validates_logo_uri_byte_length_boundaries() {
90        validate_tip20_logo_uri(&format!("https://{}", "a".repeat(248))).unwrap();
91
92        let multibyte = format!("https://{}", "é".repeat(124));
93        assert_eq!(multibyte.len(), TIP20_MAX_LOGO_URI_BYTES);
94        validate_tip20_logo_uri(&multibyte).unwrap();
95
96        let too_long = format!("https://{}é", "a".repeat(247));
97        assert_eq!(too_long.len(), TIP20_MAX_LOGO_URI_BYTES + 1);
98        assert_eq!(
99            validate_tip20_logo_uri(&too_long).unwrap_err(),
100            Tip20LogoUriValidationError::LogoURITooLong
101        );
102    }
103}