Skip to main content

r402_core/
chain.rs

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