Skip to main content

r402_protocol/network/
id.rs

1//! CAIP-2 `namespace:reference` chain identifier.
2
3use std::fmt;
4use std::str::FromStr;
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7
8/// CAIP-2 blockchain identifier (`eip155:8453`, `solana:…`).
9///
10/// Serializes as a colon-separated string.
11///
12/// # Examples
13///
14/// ```
15/// use r402_protocol::ChainId;
16///
17/// let chain = ChainId::new("eip155", "8453");
18/// assert_eq!(chain.namespace(), "eip155");
19/// assert_eq!(chain.reference(), "8453");
20/// assert_eq!(chain.to_string(), "eip155:8453");
21/// ```
22#[derive(Debug, Clone, PartialEq, Eq, Hash)]
23pub struct ChainId {
24    namespace: String,
25    reference: String,
26}
27
28impl ChainId {
29    /// Creates a chain ID from namespace and reference.
30    pub fn new<N: Into<String>, R: Into<String>>(namespace: N, reference: R) -> Self {
31        Self {
32            namespace: namespace.into(),
33            reference: reference.into(),
34        }
35    }
36
37    /// Namespace component (`eip155`, `solana`, …).
38    #[must_use]
39    pub fn namespace(&self) -> &str {
40        &self.namespace
41    }
42
43    /// Reference component (`8453`, genesis hash, …).
44    #[must_use]
45    pub fn reference(&self) -> &str {
46        &self.reference
47    }
48
49    /// Consumes the chain ID and returns `(namespace, reference)`.
50    #[must_use]
51    pub fn into_parts(self) -> (String, String) {
52        (self.namespace, self.reference)
53    }
54}
55
56impl fmt::Display for ChainId {
57    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
58        write!(f, "{}:{}", self.namespace, self.reference)
59    }
60}
61
62impl From<ChainId> for String {
63    fn from(value: ChainId) -> Self {
64        value.to_string()
65    }
66}
67
68/// Invalid `namespace:reference` string.
69#[derive(Debug, thiserror::Error)]
70#[error("Invalid chain id format {0}")]
71pub struct ChainIdFormatError(
72    /// The rejected input.
73    pub String,
74);
75
76impl FromStr for ChainId {
77    type Err = ChainIdFormatError;
78
79    fn from_str(s: &str) -> Result<Self, Self::Err> {
80        let (namespace, reference) = s
81            .split_once(':')
82            .ok_or_else(|| ChainIdFormatError(s.into()))?;
83        Ok(Self {
84            namespace: namespace.into(),
85            reference: reference.into(),
86        })
87    }
88}
89
90impl Serialize for ChainId {
91    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
92    where
93        S: Serializer,
94    {
95        serializer.serialize_str(&self.to_string())
96    }
97}
98
99impl<'de> Deserialize<'de> for ChainId {
100    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
101    where
102        D: Deserializer<'de>,
103    {
104        let s = String::deserialize(deserializer)?;
105        Self::from_str(&s).map_err(de::Error::custom)
106    }
107}
108
109/// Known network definition with its chain ID and human-readable name.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub struct NetworkInfo {
112    /// Human-readable network name (e.g. `"base-sepolia"`).
113    pub name: &'static str,
114    /// CAIP-2 namespace (e.g. `"eip155"`).
115    pub namespace: &'static str,
116    /// Chain reference (e.g. `"84532"`).
117    pub reference: &'static str,
118}
119
120impl NetworkInfo {
121    /// `ChainId` for this network.
122    #[must_use]
123    pub fn chain_id(&self) -> ChainId {
124        ChainId::new(self.namespace, self.reference)
125    }
126}
127
128#[cfg(test)]
129#[allow(clippy::unwrap_used, reason = "unit tests panic on assertion failure")]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn serialize_eip155() {
135        let chain_id = ChainId::new("eip155", "1");
136        let serialized = serde_json::to_string(&chain_id).unwrap();
137        assert_eq!(serialized, "\"eip155:1\"");
138    }
139
140    #[test]
141    fn serialize_solana() {
142        let chain_id = ChainId::new("solana", "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp");
143        let serialized = serde_json::to_string(&chain_id).unwrap();
144        assert_eq!(serialized, "\"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp\"");
145    }
146
147    #[test]
148    fn deserialize_eip155() {
149        let chain_id: ChainId = serde_json::from_str("\"eip155:1\"").unwrap();
150        assert_eq!(chain_id.namespace(), "eip155");
151        assert_eq!(chain_id.reference(), "1");
152    }
153
154    #[test]
155    fn deserialize_solana() {
156        let chain_id: ChainId =
157            serde_json::from_str("\"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp\"").unwrap();
158        assert_eq!(chain_id.namespace(), "solana");
159        assert_eq!(chain_id.reference(), "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp");
160    }
161
162    #[test]
163    fn roundtrip_eip155() {
164        let original = ChainId::new("eip155", "8453");
165        let serialized = serde_json::to_string(&original).unwrap();
166        let deserialized: ChainId = serde_json::from_str(&serialized).unwrap();
167        assert_eq!(original, deserialized);
168    }
169
170    #[test]
171    fn roundtrip_solana() {
172        let original = ChainId::new("solana", "devnet");
173        let serialized = serde_json::to_string(&original).unwrap();
174        let deserialized: ChainId = serde_json::from_str(&serialized).unwrap();
175        assert_eq!(original, deserialized);
176    }
177
178    #[test]
179    fn deserialize_invalid_format() {
180        let result: Result<ChainId, _> = serde_json::from_str("\"invalid\"");
181        assert!(result.is_err());
182    }
183
184    #[test]
185    fn deserialize_unknown_namespace() {
186        let result: Result<ChainId, _> = serde_json::from_str("\"unknown:1\"");
187        assert!(result.is_ok());
188    }
189}