tap_caip/
asset_id.rs

1use crate::chain_id::ChainId;
2use crate::error::Error;
3use crate::validation::ValidationRegistry;
4use once_cell::sync::Lazy;
5use regex::Regex;
6use serde::{Deserialize, Deserializer, Serialize, Serializer};
7use std::str::FromStr;
8
9/// Regular expression pattern for CAIP-19 asset ID validation
10static ASSET_ID_REGEX: Lazy<Regex> = Lazy::new(|| {
11    Regex::new(r"^[-a-z0-9]{3,8}:[-a-zA-Z0-9]{1,64}/[-a-z0-9]{3,8}:[-a-zA-Z0-9]{1,64}$")
12        .expect("Failed to compile ASSET_ID_REGEX")
13});
14
15/// CAIP-19 Asset ID implementation
16///
17/// An Asset ID is a string that identifies a blockchain asset and follows the format:
18/// `<chainId>/<assetNamespace>:<assetReference>`
19///
20/// - `chainId`: CAIP-2 Chain ID (e.g., "eip155:1" for Ethereum mainnet)
21/// - `assetNamespace`: Protocol or standard (e.g., "erc20")
22/// - `assetReference`: Asset-specific identifier (e.g., token contract address)
23///
24/// Example: "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48" for USDC on Ethereum
25#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26pub struct AssetId {
27    chain_id: ChainId,
28    namespace: String,
29    reference: String,
30}
31
32impl AssetId {
33    /// Create a new AssetId from ChainId, asset namespace, and reference
34    ///
35    /// # Arguments
36    ///
37    /// * `chain_id` - The CAIP-2 Chain ID
38    /// * `namespace` - The asset namespace (e.g., "erc20")
39    /// * `reference` - The asset reference (e.g., token contract address)
40    ///
41    /// # Returns
42    ///
43    /// * `Result<AssetId, Error>` - An AssetId or an error if validation fails
44    pub fn new(chain_id: ChainId, namespace: &str, reference: &str) -> Result<Self, Error> {
45        // Validate namespace format
46        Self::validate_namespace(namespace)?;
47
48        // Validate reference format
49        Self::validate_reference(namespace, reference)?;
50
51        // Validate full asset ID
52        let asset_id_str = format!("{}/{namespace}:{reference}", chain_id);
53        if !ASSET_ID_REGEX.is_match(&asset_id_str) {
54            return Err(Error::InvalidAssetId(asset_id_str));
55        }
56
57        Ok(Self {
58            chain_id,
59            namespace: namespace.to_string(),
60            reference: reference.to_string(),
61        })
62    }
63
64    /// Get the chain ID component
65    pub fn chain_id(&self) -> &ChainId {
66        &self.chain_id
67    }
68
69    /// Get the asset namespace component
70    pub fn namespace(&self) -> &str {
71        &self.namespace
72    }
73
74    /// Get the asset reference component
75    pub fn reference(&self) -> &str {
76        &self.reference
77    }
78
79    /// Validate the asset namespace
80    fn validate_namespace(namespace: &str) -> Result<(), Error> {
81        // Namespace must be 3-8 characters, lowercase alphanumeric with possible hyphens
82        if !Regex::new(r"^[-a-z0-9]{3,8}$")
83            .expect("Failed to compile namespace regex")
84            .is_match(namespace)
85        {
86            return Err(Error::InvalidAssetNamespace(namespace.to_string()));
87        }
88
89        Ok(())
90    }
91
92    /// Validate the asset reference with respect to the namespace
93    fn validate_reference(namespace: &str, reference: &str) -> Result<(), Error> {
94        // Reference must be 1-64 characters, alphanumeric with possible hyphens
95        if !Regex::new(r"^[-a-zA-Z0-9]{1,64}$")
96            .expect("Failed to compile reference regex")
97            .is_match(reference)
98        {
99            return Err(Error::InvalidAssetReference(reference.to_string()));
100        }
101
102        // Get the global validation registry
103        let registry = ValidationRegistry::global();
104        let registry_guard = registry.lock().unwrap();
105
106        // Apply namespace-specific validation rules
107        if let Some(validator) = registry_guard.get_asset_validator(namespace) {
108            validator(reference)
109                .map_err(|err| Error::InvalidAssetReference(format!("{}: {}", reference, err)))?;
110        }
111
112        Ok(())
113    }
114}
115
116impl FromStr for AssetId {
117    type Err = Error;
118
119    /// Parse a string into an AssetId
120    ///
121    /// # Arguments
122    ///
123    /// * `s` - A string in the format "namespace:reference/assetNamespace:assetReference"
124    ///
125    /// # Returns
126    ///
127    /// * `Result<AssetId, Error>` - An AssetId or an error if parsing fails
128    fn from_str(s: &str) -> Result<Self, Self::Err> {
129        // Check the overall format first
130        if !ASSET_ID_REGEX.is_match(s) {
131            return Err(Error::InvalidAssetId(s.to_string()));
132        }
133
134        // Split by "/" to separate chain ID from asset identifier
135        let parts: Vec<&str> = s.split('/').collect();
136        if parts.len() != 2 {
137            return Err(Error::InvalidAssetId(s.to_string()));
138        }
139
140        // Parse the chain ID
141        let chain_id = ChainId::from_str(parts[0])?;
142
143        // Split asset identifier by ":" to get namespace and reference
144        let asset_parts: Vec<&str> = parts[1].split(':').collect();
145        if asset_parts.len() != 2 {
146            return Err(Error::InvalidAssetId(s.to_string()));
147        }
148
149        // Create the asset ID
150        AssetId::new(chain_id, asset_parts[0], asset_parts[1])
151    }
152}
153
154// Removed the conflicting ToString implementation
155// Let the default implementation from Display be used
156
157impl std::fmt::Display for AssetId {
158    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
159        write!(f, "{}/{}:{}", self.chain_id, self.namespace, self.reference)
160    }
161}
162
163impl Serialize for AssetId {
164    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
165    where
166        S: Serializer,
167    {
168        serializer.serialize_str(&self.to_string())
169    }
170}
171
172impl<'de> Deserialize<'de> for AssetId {
173    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
174    where
175        D: Deserializer<'de>,
176    {
177        let s = String::deserialize(deserializer)?;
178        AssetId::from_str(&s).map_err(serde::de::Error::custom)
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_serialization_format() {
188        let asset_str = "eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
189        let asset_id = AssetId::from_str(asset_str).unwrap();
190
191        // Check current serialization
192        let json = serde_json::to_string(&asset_id).unwrap();
193        assert_eq!(json, format!("\"{}\"", asset_str));
194
195        // Try to deserialize from string
196        let json_string = format!("\"{}\"", asset_str);
197        let result = serde_json::from_str::<AssetId>(&json_string);
198        assert!(result.is_ok());
199
200        // Test array serialization (like in test vectors)
201        let assets = vec![
202            AssetId::from_str("eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48").unwrap(),
203            AssetId::from_str("eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F").unwrap(),
204        ];
205        let json_array = serde_json::to_string(&assets).unwrap();
206        assert_eq!(
207            json_array,
208            r#"["eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48","eip155:1/erc20:0x6B175474E89094C44Da98b954EedeAC495271d0F"]"#
209        );
210
211        // Test deserializing from array
212        let test_vector_json = r#"["eip155:1/erc20:0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48"]"#;
213        let result: Result<Vec<AssetId>, _> = serde_json::from_str(test_vector_json);
214        assert!(result.is_ok());
215    }
216
217    #[test]
218    fn test_valid_asset_ids() {
219        // Test Ethereum ERC-20 token (USDC)
220        let usdc =
221            AssetId::from_str("eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap();
222        assert_eq!(usdc.chain_id().to_string(), "eip155:1");
223        assert_eq!(usdc.namespace(), "erc20");
224        assert_eq!(
225            usdc.reference(),
226            "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
227        );
228        assert_eq!(
229            usdc.to_string(),
230            "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
231        );
232
233        // Test direct creation
234        let chain_id = ChainId::from_str("eip155:1").unwrap();
235        let dai = AssetId::new(
236            chain_id,
237            "erc20",
238            "0x6b175474e89094c44da98b954eedeac495271d0f",
239        )
240        .unwrap();
241        assert_eq!(
242            dai.to_string(),
243            "eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f"
244        );
245    }
246
247    #[test]
248    fn test_invalid_asset_ids() {
249        // Invalid: empty string
250        assert!(AssetId::from_str("").is_err());
251
252        // Invalid: missing separators
253        assert!(AssetId::from_str("eip1551erc20address").is_err());
254
255        // Invalid: missing slash
256        assert!(
257            AssetId::from_str("eip155:1erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").is_err()
258        );
259
260        // Invalid: empty namespace
261        assert!(AssetId::from_str(":1/:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").is_err());
262
263        // Invalid: empty reference
264        assert!(AssetId::from_str("eip155:1/erc20:").is_err());
265
266        // Invalid: namespace too short
267        assert!(
268            AssetId::from_str("eip155:1/er:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").is_err()
269        );
270
271        // Invalid: reference too long
272        let long_reference = "a".repeat(65);
273        assert!(AssetId::from_str(&format!("eip155:1/erc20:{}", long_reference)).is_err());
274    }
275
276    #[test]
277    fn test_serialization() {
278        let asset_id =
279            AssetId::from_str("eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap();
280        let serialized = serde_json::to_string(&asset_id).unwrap();
281
282        // The JSON representation should be a string
283        assert_eq!(
284            serialized,
285            r#""eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48""#
286        );
287
288        let deserialized: AssetId = serde_json::from_str(&serialized).unwrap();
289        assert_eq!(deserialized, asset_id);
290    }
291
292    #[test]
293    fn test_display_formatting() {
294        let asset_id =
295            AssetId::from_str("eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48").unwrap();
296        assert_eq!(
297            format!("{}", asset_id),
298            "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
299        );
300        assert_eq!(
301            asset_id.to_string(),
302            "eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"
303        );
304    }
305}