Skip to main content

x402_rs/chain/
chain_id.rs

1//! CAIP-2 chain identifier types for blockchain-agnostic identification.
2//!
3//! This module implements the [CAIP-2](https://standards.chainagnostic.org/CAIPs/caip-2) standard
4//! for identifying blockchain networks in a chain-agnostic way. A CAIP-2 chain ID
5//! consists of two parts separated by a colon:
6//!
7//! - **Namespace**: The blockchain ecosystem (e.g., `eip155` for EVM, `solana` for Solana)
8//! - **Reference**: The chain-specific identifier (e.g., `8453` for Base, `137` for Polygon)
9//!
10//! # Examples
11//!
12//! ```
13//! use x402_rs::chain::ChainId;
14//!
15//! // Create a chain ID for Base mainnet
16//! let base = ChainId::new("eip155", "8453");
17//! assert_eq!(base.to_string(), "eip155:8453");
18//!
19//! // Parse from string
20//! let polygon: ChainId = "eip155:137".parse().unwrap();
21//! assert_eq!(polygon.namespace, "eip155");
22//! assert_eq!(polygon.reference, "137");
23//! ```
24
25use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
26use std::collections::HashSet;
27use std::fmt;
28use std::str::FromStr;
29
30use crate::networks;
31
32/// A CAIP-2 compliant blockchain identifier.
33///
34/// Chain IDs uniquely identify blockchain networks across different ecosystems.
35/// The format is `namespace:reference` where:
36///
37/// - `namespace` identifies the blockchain family (e.g., `eip155`, `solana`)
38/// - `reference` identifies the specific chain within that family
39///
40/// # Serialization
41///
42/// Serializes to/from a colon-separated string: `"eip155:8453"`
43///
44/// # Example
45///
46/// ```
47/// use x402_rs::chain::ChainId;
48///
49/// let chain = ChainId::new("eip155", "8453");
50/// let json = serde_json::to_string(&chain).unwrap();
51/// assert_eq!(json, "\"eip155:8453\"");
52/// ```
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
54pub struct ChainId {
55    /// The blockchain namespace (e.g., `eip155` for EVM chains, `solana` for Solana).
56    pub namespace: String,
57    /// The chain-specific reference (e.g., `8453` for Base, `137` for Polygon).
58    pub reference: String,
59}
60
61impl ChainId {
62    /// Creates a new chain ID from namespace and reference components.
63    ///
64    /// # Example
65    ///
66    /// ```
67    /// use x402_rs::chain::ChainId;
68    ///
69    /// let base = ChainId::new("eip155", "8453");
70    /// assert_eq!(base.namespace, "eip155");
71    /// assert_eq!(base.reference, "8453");
72    /// ```
73    pub fn new<N: Into<String>, R: Into<String>>(namespace: N, reference: R) -> Self {
74        Self {
75            namespace: namespace.into(),
76            reference: reference.into(),
77        }
78    }
79
80    /// Returns the namespace component of the chain ID.
81    pub fn namespace(&self) -> &str {
82        &self.namespace
83    }
84
85    /// Returns the reference component of the chain ID.
86    pub fn reference(&self) -> &str {
87        &self.reference
88    }
89
90    /// Creates a chain ID from a well-known network name.
91    ///
92    /// This method looks up the network name in the registry of known networks
93    /// (see [`crate::networks`]) and returns the corresponding chain ID.
94    ///
95    /// # Example
96    ///
97    /// ```
98    /// use x402_rs::chain::ChainId;
99    ///
100    /// let base = ChainId::from_network_name("base").unwrap();
101    /// assert_eq!(base.to_string(), "eip155:8453");
102    ///
103    /// assert!(ChainId::from_network_name("unknown").is_none());
104    /// ```
105    pub fn from_network_name(network_name: &str) -> Option<Self> {
106        networks::chain_id_by_network_name(network_name).cloned()
107    }
108
109    /// Returns the well-known network name for this chain ID, if any.
110    ///
111    /// This is the reverse of [`ChainId::from_network_name`].
112    ///
113    /// # Example
114    ///
115    /// ```
116    /// use x402_rs::chain::ChainId;
117    ///
118    /// let base = ChainId::new("eip155", "8453");
119    /// assert_eq!(base.as_network_name(), Some("base"));
120    ///
121    /// let unknown = ChainId::new("eip155", "999999");
122    /// assert!(unknown.as_network_name().is_none());
123    /// ```
124    pub fn as_network_name(&self) -> Option<&'static str> {
125        networks::network_name_by_chain_id(self)
126    }
127}
128
129impl fmt::Display for ChainId {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        write!(f, "{}:{}", self.namespace, self.reference)
132    }
133}
134
135impl From<ChainId> for String {
136    fn from(value: ChainId) -> Self {
137        value.to_string()
138    }
139}
140
141/// Error returned when parsing an invalid chain ID string.
142///
143/// A valid chain ID must be in the format `namespace:reference` where both
144/// components are non-empty strings.
145#[derive(Debug, thiserror::Error)]
146#[error("Invalid chain id format {0}")]
147pub struct ChainIdFormatError(String);
148
149impl FromStr for ChainId {
150    type Err = ChainIdFormatError;
151
152    fn from_str(s: &str) -> Result<Self, Self::Err> {
153        let parts: Vec<&str> = s.splitn(2, ':').collect();
154        if parts.len() != 2 {
155            return Err(ChainIdFormatError(s.into()));
156        }
157        Ok(ChainId {
158            namespace: parts[0].into(),
159            reference: parts[1].into(),
160        })
161    }
162}
163
164impl Serialize for ChainId {
165    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
166    where
167        S: Serializer,
168    {
169        serializer.serialize_str(&self.to_string())
170    }
171}
172
173impl<'de> Deserialize<'de> for ChainId {
174    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
175    where
176        D: Deserializer<'de>,
177    {
178        let s = String::deserialize(deserializer)?;
179        ChainId::from_str(&s).map_err(de::Error::custom)
180    }
181}
182
183/// A pattern for matching chain IDs.
184///
185/// Chain ID patterns allow flexible matching of blockchain networks:
186///
187/// - **Wildcard**: Matches any chain within a namespace (e.g., `eip155:*` matches all EVM chains)
188/// - **Exact**: Matches a specific chain (e.g., `eip155:8453` matches only Base)
189/// - **Set**: Matches any chain from a set (e.g., `eip155:{1,8453,137}` matches Ethereum, Base, or Polygon)
190///
191/// # Serialization
192///
193/// Patterns serialize to human-readable strings:
194/// - Wildcard: `"eip155:*"`
195/// - Exact: `"eip155:8453"`
196/// - Set: `"eip155:{1,8453,137}"`
197///
198/// # Example
199///
200/// ```
201/// use x402_rs::chain::{ChainId, ChainIdPattern};
202///
203/// // Match all EVM chains
204/// let all_evm = ChainIdPattern::wildcard("eip155");
205/// assert!(all_evm.matches(&ChainId::new("eip155", "8453")));
206/// assert!(all_evm.matches(&ChainId::new("eip155", "137")));
207/// assert!(!all_evm.matches(&ChainId::new("solana", "mainnet")));
208///
209/// // Match specific chain
210/// let base_only = ChainIdPattern::exact("eip155", "8453");
211/// assert!(base_only.matches(&ChainId::new("eip155", "8453")));
212/// assert!(!base_only.matches(&ChainId::new("eip155", "137")));
213/// ```
214#[derive(Debug, Clone)]
215pub enum ChainIdPattern {
216    /// Matches any chain within the specified namespace.
217    Wildcard {
218        /// The namespace to match (e.g., `eip155`, `solana`).
219        namespace: String,
220    },
221    /// Matches exactly one specific chain.
222    Exact {
223        /// The namespace of the chain.
224        namespace: String,
225        /// The reference of the chain.
226        reference: String,
227    },
228    /// Matches any chain from a set of references within a namespace.
229    Set {
230        /// The namespace of the chains.
231        namespace: String,
232        /// The set of chain references to match.
233        references: HashSet<String>,
234    },
235}
236
237impl ChainIdPattern {
238    /// Creates a wildcard pattern that matches any chain in the given namespace.
239    ///
240    /// # Example
241    ///
242    /// ```
243    /// use x402_rs::chain::{ChainId, ChainIdPattern};
244    ///
245    /// let pattern = ChainIdPattern::wildcard("eip155");
246    /// assert!(pattern.matches(&ChainId::new("eip155", "1")));
247    /// assert!(pattern.matches(&ChainId::new("eip155", "8453")));
248    /// ```
249    pub fn wildcard<S: Into<String>>(namespace: S) -> Self {
250        Self::Wildcard {
251            namespace: namespace.into(),
252        }
253    }
254
255    /// Creates an exact pattern that matches only the specified chain.
256    ///
257    /// # Example
258    ///
259    /// ```
260    /// use x402_rs::chain::{ChainId, ChainIdPattern};
261    ///
262    /// let pattern = ChainIdPattern::exact("eip155", "8453");
263    /// assert!(pattern.matches(&ChainId::new("eip155", "8453")));
264    /// assert!(!pattern.matches(&ChainId::new("eip155", "137")));
265    /// ```
266    pub fn exact<N: Into<String>, R: Into<String>>(namespace: N, reference: R) -> Self {
267        Self::Exact {
268            namespace: namespace.into(),
269            reference: reference.into(),
270        }
271    }
272
273    /// Creates a set pattern that matches any chain from the given set of references.
274    ///
275    /// # Example
276    ///
277    /// ```
278    /// use x402_rs::chain::{ChainId, ChainIdPattern};
279    /// use std::collections::HashSet;
280    ///
281    /// let refs: HashSet<String> = ["1", "8453", "137"].iter().map(|s| s.to_string()).collect();
282    /// let pattern = ChainIdPattern::set("eip155", refs);
283    /// assert!(pattern.matches(&ChainId::new("eip155", "8453")));
284    /// assert!(!pattern.matches(&ChainId::new("eip155", "42")));
285    /// ```
286    pub fn set<N: Into<String>>(namespace: N, references: HashSet<String>) -> Self {
287        Self::Set {
288            namespace: namespace.into(),
289            references,
290        }
291    }
292
293    /// Check if a `ChainId` matches this pattern.
294    ///
295    /// - `Wildcard` matches any chain with the same namespace
296    /// - `Exact` matches only if both namespace and reference are equal
297    /// - `Set` matches if the namespace is equal and the reference is in the set
298    pub fn matches(&self, chain_id: &ChainId) -> bool {
299        match self {
300            ChainIdPattern::Wildcard { namespace } => chain_id.namespace == *namespace,
301            ChainIdPattern::Exact {
302                namespace,
303                reference,
304            } => chain_id.namespace == *namespace && chain_id.reference == *reference,
305            ChainIdPattern::Set {
306                namespace,
307                references,
308            } => chain_id.namespace == *namespace && references.contains(&chain_id.reference),
309        }
310    }
311
312    /// Returns the namespace of this pattern.
313    #[allow(dead_code)]
314    pub fn namespace(&self) -> &str {
315        match self {
316            ChainIdPattern::Wildcard { namespace } => namespace,
317            ChainIdPattern::Exact { namespace, .. } => namespace,
318            ChainIdPattern::Set { namespace, .. } => namespace,
319        }
320    }
321}
322
323impl fmt::Display for ChainIdPattern {
324    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
325        match self {
326            ChainIdPattern::Wildcard { namespace } => write!(f, "{}:*", namespace),
327            ChainIdPattern::Exact {
328                namespace,
329                reference,
330            } => write!(f, "{}:{}", namespace, reference),
331            ChainIdPattern::Set {
332                namespace,
333                references,
334            } => {
335                let refs: Vec<&str> = references.iter().map(|s| s.as_ref()).collect();
336                write!(f, "{}:{{{}}}", namespace, refs.join(","))
337            }
338        }
339    }
340}
341
342impl FromStr for ChainIdPattern {
343    type Err = ChainIdFormatError;
344
345    fn from_str(s: &str) -> Result<Self, Self::Err> {
346        let (namespace, rest) = s.split_once(':').ok_or(ChainIdFormatError(s.into()))?;
347
348        if namespace.is_empty() {
349            return Err(ChainIdFormatError(s.into()));
350        }
351
352        // Wildcard: eip155:*
353        if rest == "*" {
354            return Ok(ChainIdPattern::wildcard(namespace));
355        }
356
357        // Set: eip155:{1,2,3}
358        if let Some(inner) = rest.strip_prefix('{').and_then(|r| r.strip_suffix('}')) {
359            let mut references = HashSet::new();
360
361            for item in inner.split(',') {
362                let item = item.trim();
363                if item.is_empty() {
364                    return Err(ChainIdFormatError(s.into()));
365                }
366                references.insert(item.into());
367            }
368
369            if references.is_empty() {
370                return Err(ChainIdFormatError(s.into()));
371            }
372
373            return Ok(ChainIdPattern::set(namespace, references));
374        }
375
376        // Exact: eip155:1
377        if rest.is_empty() {
378            return Err(ChainIdFormatError(s.into()));
379        }
380
381        Ok(ChainIdPattern::exact(namespace, rest))
382    }
383}
384
385impl Serialize for ChainIdPattern {
386    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
387    where
388        S: Serializer,
389    {
390        serializer.serialize_str(&self.to_string())
391    }
392}
393
394impl<'de> Deserialize<'de> for ChainIdPattern {
395    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
396    where
397        D: Deserializer<'de>,
398    {
399        let s = String::deserialize(deserializer)?;
400        ChainIdPattern::from_str(&s).map_err(de::Error::custom)
401    }
402}
403
404impl From<ChainId> for ChainIdPattern {
405    fn from(chain_id: ChainId) -> Self {
406        ChainIdPattern::exact(chain_id.namespace, chain_id.reference)
407    }
408}
409
410impl From<ChainIdPattern> for Vec<ChainIdPattern> {
411    fn from(value: ChainIdPattern) -> Self {
412        vec![value]
413    }
414}
415
416impl From<ChainId> for Vec<ChainId> {
417    fn from(value: ChainId) -> Self {
418        vec![value]
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn test_chain_id_serialize_eip155() {
428        let chain_id = ChainId::new("eip155", "1");
429        let serialized = serde_json::to_string(&chain_id).unwrap();
430        assert_eq!(serialized, "\"eip155:1\"");
431    }
432
433    #[test]
434    fn test_chain_id_serialize_solana() {
435        let chain_id = ChainId::new("solana", "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp");
436        let serialized = serde_json::to_string(&chain_id).unwrap();
437        assert_eq!(serialized, "\"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp\"");
438    }
439
440    #[test]
441    fn test_chain_id_deserialize_eip155() {
442        let chain_id: ChainId = serde_json::from_str("\"eip155:1\"").unwrap();
443        assert_eq!(chain_id.namespace, "eip155");
444        assert_eq!(chain_id.reference, "1");
445    }
446
447    #[test]
448    fn test_chain_id_deserialize_solana() {
449        let chain_id: ChainId =
450            serde_json::from_str("\"solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp\"").unwrap();
451        assert_eq!(chain_id.namespace, "solana");
452        assert_eq!(chain_id.reference, "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp");
453    }
454
455    #[test]
456    fn test_chain_id_roundtrip_eip155() {
457        let original = ChainId::new("eip155", "8453");
458        // let original = ChainId::eip155(8453);
459        let serialized = serde_json::to_string(&original).unwrap();
460        let deserialized: ChainId = serde_json::from_str(&serialized).unwrap();
461        assert_eq!(original, deserialized);
462    }
463
464    #[test]
465    fn test_chain_id_roundtrip_solana() {
466        let original = ChainId::new("solana", "devnet");
467        let serialized = serde_json::to_string(&original).unwrap();
468        let deserialized: ChainId = serde_json::from_str(&serialized).unwrap();
469        assert_eq!(original, deserialized);
470    }
471
472    #[test]
473    fn test_chain_id_deserialize_invalid_format() {
474        let result: Result<ChainId, _> = serde_json::from_str("\"invalid\"");
475        assert!(result.is_err());
476    }
477
478    #[test]
479    fn test_chain_id_deserialize_unknown_namespace() {
480        let result: Result<ChainId, _> = serde_json::from_str("\"unknown:1\"");
481        assert!(result.is_ok());
482    }
483
484    #[test]
485    fn test_pattern_wildcard_matches() {
486        let pattern = ChainIdPattern::wildcard("eip155");
487        assert!(pattern.matches(&ChainId::new("eip155", "1")));
488        assert!(pattern.matches(&ChainId::new("eip155", "8453")));
489        assert!(pattern.matches(&ChainId::new("eip155", "137")));
490        assert!(!pattern.matches(&ChainId::new("solana", "mainnet")));
491    }
492
493    #[test]
494    fn test_pattern_exact_matches() {
495        let pattern = ChainIdPattern::exact("eip155", "1");
496        assert!(pattern.matches(&ChainId::new("eip155", "1")));
497        assert!(!pattern.matches(&ChainId::new("eip155", "8453")));
498        assert!(!pattern.matches(&ChainId::new("solana", "1")));
499    }
500
501    #[test]
502    fn test_pattern_set_matches() {
503        let references: HashSet<String> = vec!["1", "8453", "137"]
504            .into_iter()
505            .map(String::from)
506            .collect();
507        let pattern = ChainIdPattern::set("eip155", references);
508        assert!(pattern.matches(&ChainId::new("eip155", "1")));
509        assert!(pattern.matches(&ChainId::new("eip155", "8453")));
510        assert!(pattern.matches(&ChainId::new("eip155", "137")));
511        assert!(!pattern.matches(&ChainId::new("eip155", "42")));
512        assert!(!pattern.matches(&ChainId::new("solana", "1")));
513    }
514
515    #[test]
516    fn test_pattern_namespace() {
517        let wildcard = ChainIdPattern::wildcard("eip155");
518        assert_eq!(wildcard.namespace(), "eip155");
519
520        let exact = ChainIdPattern::exact("solana", "mainnet");
521        assert_eq!(exact.namespace(), "solana");
522
523        let references: HashSet<String> = vec!["1"].into_iter().map(String::from).collect();
524        let set = ChainIdPattern::set("eip155", references);
525        assert_eq!(set.namespace(), "eip155");
526    }
527}