Skip to main content

wavs_types/id/
chain.rs

1use std::{str::FromStr, sync::LazyLock};
2
3use regex::Regex;
4use serde::{Deserialize, Deserializer, Serialize};
5use thiserror::Error;
6#[cfg(feature = "ts-bindings")]
7use ts_rs::TS;
8use utoipa::ToSchema;
9
10/// A `ChainKey` represents a blockchain network identifier, consisting of a namespace and a chain id.
11/// The namespace indicates the type of blockchain (e.g., "ethereum", "bitcoin"),
12/// while the id specifies the particular network within that namespace (e.g., "1", "cosmoshub").
13///
14/// Mostly follows the specification at https://chainagnostic.org/CAIPs/caip-2
15/// but allows the namespace part to 1 to 32 characters instead of 3 to 8
16/// and changes the naming of chain_id -> chain_key, reference -> chain_id
17#[cfg_attr(feature = "ts-bindings", derive(TS))]
18#[cfg_attr(feature = "ts-bindings", ts(export, type = "string"))]
19#[derive(
20    Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, ToSchema, bincode::Decode, bincode::Encode,
21)]
22pub struct ChainKey {
23    pub namespace: ChainKeyNamespace,
24    pub id: ChainKeyId,
25}
26
27type ChainKeyResult<T> = Result<T, ChainKeyError>;
28
29static CHAIN_KEY_REGEX: LazyLock<Regex> =
30    LazyLock::new(|| Regex::new(r"^([-a-z0-9]{1,32}):([-_a-zA-Z0-9]{1,32})$").unwrap());
31
32static NAMESPACE_REGEX: LazyLock<Regex> =
33    LazyLock::new(|| Regex::new(r"^[-a-z0-9]{1,32}$").unwrap());
34
35static ID_REGEX: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[-_a-zA-Z0-9]{1,32}$").unwrap());
36
37impl ChainKey {
38    /// Validates without taking ownership - good for checking
39    pub fn validate(s: impl AsRef<str>) -> ChainKeyResult<()> {
40        let s = s.as_ref();
41        if !CHAIN_KEY_REGEX.is_match(s) {
42            Err(ChainKeyError::FormatError)
43        } else {
44            Ok(())
45        }
46    }
47
48    /// Construct a new validated `ChainKey`
49    pub fn new(s: impl Into<String>) -> ChainKeyResult<Self> {
50        let s = s.into();
51        let captures = CHAIN_KEY_REGEX
52            .captures(&s)
53            .ok_or(ChainKeyError::FormatError)?;
54
55        let namespace = ChainKeyNamespace::new(&captures[1])?;
56        let id = ChainKeyId::new(&captures[2])?;
57
58        Ok(Self { namespace, id })
59    }
60}
61
62impl<'de> Deserialize<'de> for ChainKey {
63    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
64    where
65        D: Deserializer<'de>,
66    {
67        let s = String::deserialize(deserializer)?;
68        Self::new(s).map_err(serde::de::Error::custom)
69    }
70}
71
72impl Serialize for ChainKey {
73    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
74    where
75        S: serde::Serializer,
76    {
77        serializer.serialize_str(&self.to_string())
78    }
79}
80
81impl std::fmt::Display for ChainKey {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        write!(f, "{}:{}", self.namespace, self.id)
84    }
85}
86
87impl TryFrom<&str> for ChainKey {
88    type Error = ChainKeyError;
89    fn try_from(s: &str) -> Result<Self, Self::Error> {
90        s.parse()
91    }
92}
93
94impl FromStr for ChainKey {
95    type Err = ChainKeyError;
96    fn from_str(s: &str) -> Result<Self, Self::Err> {
97        Self::new(s)
98    }
99}
100
101impl From<ChainKey> for layer_climb_config::ChainId {
102    fn from(key: ChainKey) -> Self {
103        // climb doesn't care about the namespace at all
104        layer_climb_config::ChainId::new(key.id.into_inner())
105    }
106}
107
108// ----------------------------
109// ChainKeyNamespace
110// ----------------------------
111#[cfg_attr(feature = "ts-bindings", derive(TS))]
112#[derive(
113    Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, ToSchema, bincode::Decode, bincode::Encode,
114)]
115pub struct ChainKeyNamespace(String);
116
117impl ChainKeyNamespace {
118    pub const EVM: &str = "evm";
119    pub const COSMOS: &str = "cosmos";
120    pub const DEV: &str = "dev";
121
122    pub fn new(s: impl Into<String>) -> ChainKeyResult<Self> {
123        let s = s.into();
124        if NAMESPACE_REGEX.is_match(&s) {
125            Ok(Self(s))
126        } else {
127            Err(ChainKeyError::InvalidNamespace)
128        }
129    }
130    pub fn as_str(&self) -> &str {
131        &self.0
132    }
133    pub fn into_inner(self) -> String {
134        self.0
135    }
136}
137
138impl FromStr for ChainKeyNamespace {
139    type Err = ChainKeyError;
140    fn from_str(s: &str) -> Result<Self, Self::Err> {
141        Self::new(s.to_string())
142    }
143}
144
145impl TryFrom<&str> for ChainKeyNamespace {
146    type Error = ChainKeyError;
147    fn try_from(s: &str) -> Result<Self, Self::Error> {
148        s.parse()
149    }
150}
151
152impl std::fmt::Display for ChainKeyNamespace {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        f.write_str(&self.0)
155    }
156}
157
158impl<'de> Deserialize<'de> for ChainKeyNamespace {
159    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
160    where
161        D: Deserializer<'de>,
162    {
163        let s = String::deserialize(deserializer)?;
164        Self::new(s).map_err(serde::de::Error::custom)
165    }
166}
167
168impl Serialize for ChainKeyNamespace {
169    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
170    where
171        S: serde::Serializer,
172    {
173        serializer.serialize_str(&self.0)
174    }
175}
176
177// ----------------------------
178// ChainKeyId
179// ----------------------------
180#[cfg_attr(feature = "ts-bindings", derive(TS))]
181#[derive(
182    Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, ToSchema, bincode::Decode, bincode::Encode,
183)]
184pub struct ChainKeyId(String);
185
186impl ChainKeyId {
187    pub fn new(s: impl Into<String>) -> ChainKeyResult<Self> {
188        let s = s.into();
189        if ID_REGEX.is_match(&s) {
190            Ok(Self(s))
191        } else {
192            Err(ChainKeyError::InvalidId)
193        }
194    }
195    pub fn as_str(&self) -> &str {
196        &self.0
197    }
198    pub fn into_inner(self) -> String {
199        self.0
200    }
201}
202
203impl FromStr for ChainKeyId {
204    type Err = ChainKeyError;
205    fn from_str(s: &str) -> Result<Self, Self::Err> {
206        Self::new(s.to_string())
207    }
208}
209
210impl TryFrom<&str> for ChainKeyId {
211    type Error = ChainKeyError;
212    fn try_from(s: &str) -> Result<Self, Self::Error> {
213        s.parse()
214    }
215}
216
217impl std::fmt::Display for ChainKeyId {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        f.write_str(&self.0)
220    }
221}
222
223impl<'de> Deserialize<'de> for ChainKeyId {
224    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
225    where
226        D: Deserializer<'de>,
227    {
228        let s = String::deserialize(deserializer)?;
229        Self::new(s).map_err(serde::de::Error::custom)
230    }
231}
232
233impl Serialize for ChainKeyId {
234    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
235    where
236        S: serde::Serializer,
237    {
238        serializer.serialize_str(&self.0)
239    }
240}
241
242impl From<ChainKeyId> for layer_climb_config::ChainId {
243    fn from(id: ChainKeyId) -> Self {
244        layer_climb_config::ChainId::new(id.into_inner())
245    }
246}
247
248// ----------------------------
249// Errors
250// ----------------------------
251#[derive(Error, Debug, PartialEq, Eq, Clone)]
252pub enum ChainKeyError {
253    #[error("ChainKey must follow the CAIP-2-like format of 'namespace:id'")]
254    FormatError,
255    #[error("Invalid namespace component")]
256    InvalidNamespace,
257    #[error("Invalid id component")]
258    InvalidId,
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn test_valid_chain_ids() {
267        assert!(ChainKey::new("eip155:1").is_ok());
268        assert!(ChainKey::new("cosmos:cosmoshub-4").is_ok());
269        assert!(ChainKey::new("polkadot:b0a8d493285c2df73290dfb7e61f870f").is_ok());
270        assert!(ChainKey::new("abc:X_Y-Z123").is_ok());
271        assert!(ChainKey::new("abc-123:ABC_xyz-123").is_ok());
272    }
273
274    #[test]
275    fn test_invalid_chain_ids() {
276        // Invalid format
277        assert!(ChainKey::new("no-colon").is_err());
278        assert!(ChainKey::new("too:many:colons").is_err());
279        assert!(ChainKey::new(":empty-namespace").is_err());
280        assert!(ChainKey::new("empty-id:").is_err());
281
282        // Invalid namespace
283        assert!(ChainKey::new("thisiswaytoolongtobeavalidnamespace:ref").is_err()); // too long
284        assert!(ChainKey::new("ABC:ref").is_err()); // uppercase not allowed
285        assert!(ChainKey::new("ab_c:ref").is_err()); // underscore not allowed in namespace
286        assert!(ChainKey::new("ab.c:ref").is_err()); // dot not allowed
287
288        // Invalid id
289        assert!(ChainKey::new("abc:").is_err()); // empty reference
290        assert!(ChainKey::new("abc:this-is-way-too-long-to-be-a-valid-idt").is_err()); // too long (33 chars)
291        assert!(ChainKey::new("abc:id.with.dots").is_err()); // dots not allowed
292        assert!(ChainKey::new("abc:id@123").is_err()); // @ not allowed
293        assert!(ChainKey::new("abc:id space").is_err()); // space not allowed
294    }
295
296    #[test]
297    fn test_accessors() {
298        let chain_key = ChainKey::new("eip155:1").unwrap();
299        assert_eq!(chain_key.to_string(), "eip155:1");
300        assert_eq!(chain_key.namespace.as_str(), "eip155");
301        assert_eq!(chain_key.id.as_str(), "1");
302    }
303
304    #[test]
305    fn test_edge_cases() {
306        // Minimum valid lengths
307        assert!(ChainKey::new("abc:x").is_ok());
308
309        // Maximum valid lengths
310        assert!(ChainKey::new("abcd1234:ABCD1234_abcd1234-ABCD1234").is_ok());
311
312        // Case sensitivity is preserved
313        let chain_key = ChainKey::new("eip155:MyChain_123").unwrap();
314        assert_eq!(chain_key.id.as_str(), "MyChain_123");
315    }
316}