Skip to main content

ows_core/
caip.rs

1use crate::error::OwsError;
2use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
3use std::fmt;
4use std::hash::{Hash, Hasher};
5use std::str::FromStr;
6
7/// CAIP-2 Chain ID: `namespace:reference`
8#[derive(Debug, Clone, Eq)]
9pub struct ChainId {
10    pub namespace: String,
11    pub reference: String,
12}
13
14impl ChainId {
15    fn validate_namespace(ns: &str) -> Result<(), OwsError> {
16        if ns.len() < 3 || ns.len() > 8 {
17            return Err(OwsError::CaipParseError {
18                message: format!(
19                    "namespace must be 3-8 characters, got {} ('{}')",
20                    ns.len(),
21                    ns
22                ),
23            });
24        }
25        if !ns
26            .chars()
27            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
28        {
29            return Err(OwsError::CaipParseError {
30                message: format!("namespace must be [a-z0-9], got '{}'", ns),
31            });
32        }
33        Ok(())
34    }
35
36    fn validate_reference(reference: &str) -> Result<(), OwsError> {
37        if reference.is_empty() || reference.len() > 64 {
38            return Err(OwsError::CaipParseError {
39                message: format!(
40                    "reference must be 1-64 characters, got {} ('{}')",
41                    reference.len(),
42                    reference
43                ),
44            });
45        }
46        if !reference
47            .chars()
48            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
49        {
50            return Err(OwsError::CaipParseError {
51                message: format!("reference contains invalid characters: '{}'", reference),
52            });
53        }
54        Ok(())
55    }
56}
57
58impl FromStr for ChainId {
59    type Err = OwsError;
60
61    fn from_str(s: &str) -> Result<Self, Self::Err> {
62        let parts: Vec<&str> = s.splitn(2, ':').collect();
63        if parts.len() != 2 {
64            return Err(OwsError::CaipParseError {
65                message: format!("expected 'namespace:reference', got '{}'", s),
66            });
67        }
68        let namespace = parts[0].to_string();
69        let reference = parts[1].to_string();
70        Self::validate_namespace(&namespace)?;
71        Self::validate_reference(&reference)?;
72        Ok(ChainId {
73            namespace,
74            reference,
75        })
76    }
77}
78
79impl fmt::Display for ChainId {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        write!(f, "{}:{}", self.namespace, self.reference)
82    }
83}
84
85impl PartialEq for ChainId {
86    fn eq(&self, other: &Self) -> bool {
87        self.namespace == other.namespace && self.reference == other.reference
88    }
89}
90
91impl Hash for ChainId {
92    fn hash<H: Hasher>(&self, state: &mut H) {
93        self.namespace.hash(state);
94        self.reference.hash(state);
95    }
96}
97
98impl Serialize for ChainId {
99    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
100        serializer.serialize_str(&self.to_string())
101    }
102}
103
104impl<'de> Deserialize<'de> for ChainId {
105    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
106        let s = String::deserialize(deserializer)?;
107        ChainId::from_str(&s).map_err(de::Error::custom)
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn test_parse_evm_chain_id() {
117        let id: ChainId = "eip155:1".parse().unwrap();
118        assert_eq!(id.namespace, "eip155");
119        assert_eq!(id.reference, "1");
120    }
121
122    #[test]
123    fn test_parse_solana_chain_id() {
124        let id: ChainId = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp".parse().unwrap();
125        assert_eq!(id.namespace, "solana");
126    }
127
128    #[test]
129    fn test_parse_cosmos_chain_id() {
130        let id: ChainId = "cosmos:cosmoshub-4".parse().unwrap();
131        assert_eq!(id.namespace, "cosmos");
132        assert_eq!(id.reference, "cosmoshub-4");
133    }
134
135    #[test]
136    fn test_parse_bitcoin_chain_id() {
137        let id: ChainId = "bip122:000000000019d6689c085ae165831e93".parse().unwrap();
138        assert_eq!(id.namespace, "bip122");
139    }
140
141    #[test]
142    fn test_parse_tron_chain_id() {
143        let id: ChainId = "tron:mainnet".parse().unwrap();
144        assert_eq!(id.namespace, "tron");
145        assert_eq!(id.reference, "mainnet");
146    }
147
148    #[test]
149    fn test_reject_empty_namespace() {
150        assert!("".parse::<ChainId>().is_err());
151    }
152
153    #[test]
154    fn test_reject_short_namespace() {
155        assert!("ab:1".parse::<ChainId>().is_err());
156    }
157
158    #[test]
159    fn test_reject_long_namespace() {
160        assert!("abcdefghi:1".parse::<ChainId>().is_err());
161    }
162
163    #[test]
164    fn test_reject_uppercase_namespace() {
165        assert!("EIP155:1".parse::<ChainId>().is_err());
166    }
167
168    #[test]
169    fn test_reject_no_colon() {
170        assert!("eip1551".parse::<ChainId>().is_err());
171    }
172
173    #[test]
174    fn test_display_roundtrip() {
175        let id: ChainId = "eip155:1".parse().unwrap();
176        assert_eq!(id.to_string(), "eip155:1");
177        let id2: ChainId = id.to_string().parse().unwrap();
178        assert_eq!(id, id2);
179    }
180
181    #[test]
182    fn test_serde_roundtrip() {
183        let id: ChainId = "eip155:1".parse().unwrap();
184        let json = serde_json::to_string(&id).unwrap();
185        assert_eq!(json, "\"eip155:1\"");
186        let id2: ChainId = serde_json::from_str(&json).unwrap();
187        assert_eq!(id, id2);
188    }
189
190    #[test]
191    fn test_chain_id_hash() {
192        use std::collections::HashSet;
193        let mut set = HashSet::new();
194        let id1: ChainId = "eip155:1".parse().unwrap();
195        let id2: ChainId = "eip155:1".parse().unwrap();
196        set.insert(id1);
197        assert!(set.contains(&id2));
198    }
199}