Skip to main content

ows_core/
chain.rs

1use crate::caip::ChainId;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4use std::str::FromStr;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
7#[serde(rename_all = "lowercase")]
8pub enum ChainType {
9    Evm,
10    Solana,
11    Cosmos,
12    Bitcoin,
13    Tron,
14    Ton,
15    Spark,
16    Filecoin,
17    Sui,
18    Xrpl,
19    Nano,
20}
21
22/// All supported chain families, used for universal wallet derivation.
23pub const ALL_CHAIN_TYPES: [ChainType; 10] = [
24    ChainType::Evm,
25    ChainType::Solana,
26    ChainType::Bitcoin,
27    ChainType::Cosmos,
28    ChainType::Tron,
29    ChainType::Ton,
30    ChainType::Filecoin,
31    ChainType::Sui,
32    ChainType::Xrpl,
33    ChainType::Nano,
34];
35
36/// A specific chain (e.g. "ethereum", "arbitrum") with its family type and CAIP-2 ID.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct Chain {
39    pub name: &'static str,
40    pub chain_type: ChainType,
41    pub chain_id: &'static str,
42}
43
44impl Chain {
45    /// Return the EIP-155 reference portion of this chain's CAIP-2 ID.
46    pub fn evm_chain_reference(&self) -> Result<&str, String> {
47        if self.chain_type != ChainType::Evm {
48            return Err(format!("chain '{}' is not an EVM chain", self.chain_id));
49        }
50
51        let chain_id = self
52            .chain_id
53            .parse::<ChainId>()
54            .map_err(|e| e.to_string())?;
55        if chain_id.namespace != "eip155" {
56            return Err(format!(
57                "EVM chain '{}' is missing an eip155 reference",
58                self.chain_id
59            ));
60        }
61
62        self.chain_id
63            .split_once(':')
64            .map(|(_, reference)| reference)
65            .ok_or_else(|| format!("invalid CAIP-2 chain ID: '{}'", self.chain_id))
66    }
67
68    /// Return the numeric EIP-155 chain ID for an EVM chain.
69    pub fn evm_chain_id_u64(&self) -> Result<u64, String> {
70        self.evm_chain_reference()?
71            .parse()
72            .map_err(|_| format!("cannot extract numeric chain ID from: {}", self.chain_id))
73    }
74}
75
76/// Known chains registry.
77pub const KNOWN_CHAINS: &[Chain] = &[
78    Chain {
79        name: "ethereum",
80        chain_type: ChainType::Evm,
81        chain_id: "eip155:1",
82    },
83    Chain {
84        name: "polygon",
85        chain_type: ChainType::Evm,
86        chain_id: "eip155:137",
87    },
88    Chain {
89        name: "arbitrum",
90        chain_type: ChainType::Evm,
91        chain_id: "eip155:42161",
92    },
93    Chain {
94        name: "optimism",
95        chain_type: ChainType::Evm,
96        chain_id: "eip155:10",
97    },
98    Chain {
99        name: "base",
100        chain_type: ChainType::Evm,
101        chain_id: "eip155:8453",
102    },
103    Chain {
104        name: "plasma",
105        chain_type: ChainType::Evm,
106        chain_id: "eip155:9745",
107    },
108    Chain {
109        name: "bsc",
110        chain_type: ChainType::Evm,
111        chain_id: "eip155:56",
112    },
113    Chain {
114        name: "avalanche",
115        chain_type: ChainType::Evm,
116        chain_id: "eip155:43114",
117    },
118    Chain {
119        name: "etherlink",
120        chain_type: ChainType::Evm,
121        chain_id: "eip155:42793",
122    },
123    Chain {
124        name: "solana",
125        chain_type: ChainType::Solana,
126        chain_id: "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp",
127    },
128    Chain {
129        name: "bitcoin",
130        chain_type: ChainType::Bitcoin,
131        chain_id: "bip122:000000000019d6689c085ae165831e93",
132    },
133    Chain {
134        name: "cosmos",
135        chain_type: ChainType::Cosmos,
136        chain_id: "cosmos:cosmoshub-4",
137    },
138    Chain {
139        name: "tron",
140        chain_type: ChainType::Tron,
141        chain_id: "tron:mainnet",
142    },
143    Chain {
144        name: "ton",
145        chain_type: ChainType::Ton,
146        chain_id: "ton:mainnet",
147    },
148    Chain {
149        name: "spark",
150        chain_type: ChainType::Spark,
151        chain_id: "spark:mainnet",
152    },
153    Chain {
154        name: "filecoin",
155        chain_type: ChainType::Filecoin,
156        chain_id: "fil:mainnet",
157    },
158    Chain {
159        name: "sui",
160        chain_type: ChainType::Sui,
161        chain_id: "sui:mainnet",
162    },
163    Chain {
164        name: "xrpl",
165        chain_type: ChainType::Xrpl,
166        chain_id: "xrpl:mainnet",
167    },
168    Chain {
169        name: "xrpl-testnet",
170        chain_type: ChainType::Xrpl,
171        chain_id: "xrpl:testnet",
172    },
173    Chain {
174        name: "xrpl-devnet",
175        chain_type: ChainType::Xrpl,
176        chain_id: "xrpl:devnet",
177    },
178    Chain {
179        name: "nano",
180        chain_type: ChainType::Nano,
181        chain_id: "nano:mainnet",
182    },
183    Chain {
184        name: "tempo",
185        chain_type: ChainType::Evm,
186        chain_id: "eip155:4217",
187    },
188    Chain {
189        name: "hyperliquid",
190        chain_type: ChainType::Evm,
191        chain_id: "eip155:999",
192    },
193];
194
195/// Parse a chain string into a `Chain`. Accepts:
196/// - Friendly names: "ethereum", "base", "arbitrum", "solana", etc.
197/// - CAIP-2 chain IDs: "eip155:1", "eip155:8453", etc.
198/// - Bare numeric EVM chain IDs: "8453" → eip155:8453
199/// - Legacy "evm" (deprecated, warns on stderr, resolves to ethereum)
200pub fn parse_chain(s: &str) -> Result<Chain, String> {
201    let lower = s.to_lowercase();
202
203    // Legacy "evm" — deprecated, warn and resolve
204    if lower == "evm" {
205        eprintln!(
206            "warning: '--chain evm' is deprecated; use '--chain ethereum' \
207             or a specific chain name (base, arbitrum, polygon, ...)"
208        );
209        return Ok(*KNOWN_CHAINS.iter().find(|c| c.name == "ethereum").unwrap());
210    }
211
212    // Try friendly name match
213    if let Some(chain) = KNOWN_CHAINS.iter().find(|c| c.name == lower) {
214        return Ok(*chain);
215    }
216
217    // Try CAIP-2 chain ID match
218    if let Some(chain) = KNOWN_CHAINS.iter().find(|c| c.chain_id == s) {
219        return Ok(*chain);
220    }
221
222    // Bare numeric → treat as EVM chain ID (eip155:<n>)
223    if !lower.is_empty() && lower.chars().all(|c| c.is_ascii_digit()) {
224        let caip2 = format!("eip155:{}", lower);
225        if let Some(chain) = KNOWN_CHAINS.iter().find(|c| c.chain_id == caip2) {
226            return Ok(*chain);
227        }
228        let leaked: &'static str = Box::leak(caip2.into_boxed_str());
229        return Ok(Chain {
230            name: leaked,
231            chain_type: ChainType::Evm,
232            chain_id: leaked,
233        });
234    }
235
236    // Try namespace match for unknown CAIP-2 IDs (e.g. eip155:4217, eip155:84532).
237    // Uses the same signer as the namespace's default chain. The chain_id string is
238    // leaked to satisfy the 'static lifetime — acceptable since parse_chain is called
239    // with a small, bounded set of user-supplied chain identifiers.
240    if let Some((namespace, _reference)) = s.split_once(':') {
241        if let Some(ct) = ChainType::from_namespace(namespace) {
242            let leaked: &'static str = Box::leak(s.to_string().into_boxed_str());
243            return Ok(Chain {
244                name: leaked,
245                chain_type: ct,
246                chain_id: leaked,
247            });
248        }
249    }
250
251    Err(format!(
252        "unknown chain: '{s}'\n\n\
253         Supported chains:\n  \
254           EVM:     ethereum, base, arbitrum, optimism, polygon, bsc, avalanche, plasma, etherlink\n  \
255           Solana:  solana\n  \
256           Bitcoin: bitcoin\n  \
257           Other:   cosmos, tron, ton, sui, filecoin, spark, xrpl, nano\n\n\
258         Or use a CAIP-2 ID (eip155:8453) or bare EVM chain ID (8453)"
259    ))
260}
261
262/// Returns the default `Chain` for a given `ChainType` (first match in registry).
263pub fn default_chain_for_type(ct: ChainType) -> Chain {
264    *KNOWN_CHAINS.iter().find(|c| c.chain_type == ct).unwrap()
265}
266
267impl ChainType {
268    /// Returns the CAIP-2 namespace for this chain type.
269    pub fn namespace(&self) -> &'static str {
270        match self {
271            ChainType::Evm => "eip155",
272            ChainType::Solana => "solana",
273            ChainType::Cosmos => "cosmos",
274            ChainType::Bitcoin => "bip122",
275            ChainType::Tron => "tron",
276            ChainType::Ton => "ton",
277            ChainType::Spark => "spark",
278            ChainType::Filecoin => "fil",
279            ChainType::Sui => "sui",
280            ChainType::Xrpl => "xrpl",
281            ChainType::Nano => "nano",
282        }
283    }
284
285    /// Returns the BIP-44 coin type for this chain type.
286    pub fn default_coin_type(&self) -> u32 {
287        match self {
288            ChainType::Evm => 60,
289            ChainType::Solana => 501,
290            ChainType::Cosmos => 118,
291            ChainType::Bitcoin => 0,
292            ChainType::Tron => 195,
293            ChainType::Ton => 607,
294            ChainType::Spark => 8797555,
295            ChainType::Filecoin => 461,
296            ChainType::Sui => 784,
297            ChainType::Xrpl => 144,
298            ChainType::Nano => 165,
299        }
300    }
301
302    /// Returns the ChainType for a given CAIP-2 namespace.
303    pub fn from_namespace(ns: &str) -> Option<ChainType> {
304        match ns {
305            "eip155" => Some(ChainType::Evm),
306            "solana" => Some(ChainType::Solana),
307            "cosmos" => Some(ChainType::Cosmos),
308            "bip122" => Some(ChainType::Bitcoin),
309            "tron" => Some(ChainType::Tron),
310            "ton" => Some(ChainType::Ton),
311            "spark" => Some(ChainType::Spark),
312            "fil" => Some(ChainType::Filecoin),
313            "sui" => Some(ChainType::Sui),
314            "xrpl" => Some(ChainType::Xrpl),
315            "nano" => Some(ChainType::Nano),
316            _ => None,
317        }
318    }
319}
320
321impl fmt::Display for ChainType {
322    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
323        let s = match self {
324            ChainType::Evm => "evm",
325            ChainType::Solana => "solana",
326            ChainType::Cosmos => "cosmos",
327            ChainType::Bitcoin => "bitcoin",
328            ChainType::Tron => "tron",
329            ChainType::Ton => "ton",
330            ChainType::Spark => "spark",
331            ChainType::Filecoin => "filecoin",
332            ChainType::Sui => "sui",
333            ChainType::Xrpl => "xrpl",
334            ChainType::Nano => "nano",
335        };
336        write!(f, "{}", s)
337    }
338}
339
340impl FromStr for ChainType {
341    type Err = String;
342
343    fn from_str(s: &str) -> Result<Self, Self::Err> {
344        match s.to_lowercase().as_str() {
345            "evm" => Ok(ChainType::Evm),
346            "solana" => Ok(ChainType::Solana),
347            "cosmos" => Ok(ChainType::Cosmos),
348            "bitcoin" => Ok(ChainType::Bitcoin),
349            "tron" => Ok(ChainType::Tron),
350            "ton" => Ok(ChainType::Ton),
351            "spark" => Ok(ChainType::Spark),
352            "filecoin" => Ok(ChainType::Filecoin),
353            "sui" => Ok(ChainType::Sui),
354            "xrpl" => Ok(ChainType::Xrpl),
355            "nano" => Ok(ChainType::Nano),
356            _ => Err(format!("unknown chain type: {}", s)),
357        }
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_serde_roundtrip() {
367        let chain = ChainType::Evm;
368        let json = serde_json::to_string(&chain).unwrap();
369        assert_eq!(json, "\"evm\"");
370        let chain2: ChainType = serde_json::from_str(&json).unwrap();
371        assert_eq!(chain, chain2);
372    }
373
374    #[test]
375    fn test_serde_all_variants() {
376        for (chain, expected) in [
377            (ChainType::Evm, "\"evm\""),
378            (ChainType::Solana, "\"solana\""),
379            (ChainType::Cosmos, "\"cosmos\""),
380            (ChainType::Bitcoin, "\"bitcoin\""),
381            (ChainType::Tron, "\"tron\""),
382            (ChainType::Ton, "\"ton\""),
383            (ChainType::Spark, "\"spark\""),
384            (ChainType::Filecoin, "\"filecoin\""),
385            (ChainType::Sui, "\"sui\""),
386            (ChainType::Xrpl, "\"xrpl\""),
387            (ChainType::Nano, "\"nano\""),
388        ] {
389            let json = serde_json::to_string(&chain).unwrap();
390            assert_eq!(json, expected);
391            let deserialized: ChainType = serde_json::from_str(&json).unwrap();
392            assert_eq!(chain, deserialized);
393        }
394    }
395
396    #[test]
397    fn test_namespace_mapping() {
398        assert_eq!(ChainType::Evm.namespace(), "eip155");
399        assert_eq!(ChainType::Solana.namespace(), "solana");
400        assert_eq!(ChainType::Cosmos.namespace(), "cosmos");
401        assert_eq!(ChainType::Bitcoin.namespace(), "bip122");
402        assert_eq!(ChainType::Tron.namespace(), "tron");
403        assert_eq!(ChainType::Ton.namespace(), "ton");
404        assert_eq!(ChainType::Spark.namespace(), "spark");
405        assert_eq!(ChainType::Filecoin.namespace(), "fil");
406        assert_eq!(ChainType::Sui.namespace(), "sui");
407        assert_eq!(ChainType::Xrpl.namespace(), "xrpl");
408        assert_eq!(ChainType::Nano.namespace(), "nano");
409    }
410
411    #[test]
412    fn test_coin_type_mapping() {
413        assert_eq!(ChainType::Evm.default_coin_type(), 60);
414        assert_eq!(ChainType::Solana.default_coin_type(), 501);
415        assert_eq!(ChainType::Cosmos.default_coin_type(), 118);
416        assert_eq!(ChainType::Bitcoin.default_coin_type(), 0);
417        assert_eq!(ChainType::Tron.default_coin_type(), 195);
418        assert_eq!(ChainType::Ton.default_coin_type(), 607);
419        assert_eq!(ChainType::Spark.default_coin_type(), 8797555);
420        assert_eq!(ChainType::Filecoin.default_coin_type(), 461);
421        assert_eq!(ChainType::Sui.default_coin_type(), 784);
422        assert_eq!(ChainType::Xrpl.default_coin_type(), 144);
423        assert_eq!(ChainType::Nano.default_coin_type(), 165);
424    }
425
426    #[test]
427    fn test_from_namespace() {
428        assert_eq!(ChainType::from_namespace("eip155"), Some(ChainType::Evm));
429        assert_eq!(ChainType::from_namespace("solana"), Some(ChainType::Solana));
430        assert_eq!(ChainType::from_namespace("cosmos"), Some(ChainType::Cosmos));
431        assert_eq!(
432            ChainType::from_namespace("bip122"),
433            Some(ChainType::Bitcoin)
434        );
435        assert_eq!(ChainType::from_namespace("tron"), Some(ChainType::Tron));
436        assert_eq!(ChainType::from_namespace("ton"), Some(ChainType::Ton));
437        assert_eq!(ChainType::from_namespace("spark"), Some(ChainType::Spark));
438        assert_eq!(ChainType::from_namespace("fil"), Some(ChainType::Filecoin));
439        assert_eq!(ChainType::from_namespace("sui"), Some(ChainType::Sui));
440        assert_eq!(ChainType::from_namespace("xrpl"), Some(ChainType::Xrpl));
441        assert_eq!(ChainType::from_namespace("nano"), Some(ChainType::Nano));
442        assert_eq!(ChainType::from_namespace("unknown"), None);
443    }
444
445    #[test]
446    fn test_from_str() {
447        assert_eq!("evm".parse::<ChainType>().unwrap(), ChainType::Evm);
448        assert_eq!("Solana".parse::<ChainType>().unwrap(), ChainType::Solana);
449        assert!("unknown".parse::<ChainType>().is_err());
450    }
451
452    #[test]
453    fn test_display() {
454        assert_eq!(ChainType::Evm.to_string(), "evm");
455        assert_eq!(ChainType::Bitcoin.to_string(), "bitcoin");
456    }
457
458    #[test]
459    fn test_parse_chain_friendly_name() {
460        let chain = parse_chain("ethereum").unwrap();
461        assert_eq!(chain.name, "ethereum");
462        assert_eq!(chain.chain_type, ChainType::Evm);
463        assert_eq!(chain.chain_id, "eip155:1");
464    }
465
466    #[test]
467    fn test_parse_chain_plasma_alias() {
468        let chain = parse_chain("plasma").unwrap();
469        assert_eq!(chain.name, "plasma");
470        assert_eq!(chain.chain_type, ChainType::Evm);
471        assert_eq!(chain.chain_id, "eip155:9745");
472    }
473
474    #[test]
475    fn test_parse_chain_etherlink_alias() {
476        let chain = parse_chain("etherlink").unwrap();
477        assert_eq!(chain.name, "etherlink");
478        assert_eq!(chain.chain_type, ChainType::Evm);
479        assert_eq!(chain.chain_id, "eip155:42793");
480    }
481
482    #[test]
483    fn test_parse_chain_caip2() {
484        let chain = parse_chain("eip155:42161").unwrap();
485        assert_eq!(chain.name, "arbitrum");
486        assert_eq!(chain.chain_type, ChainType::Evm);
487    }
488
489    #[test]
490    fn test_parse_chain_plasma_caip2() {
491        let chain = parse_chain("eip155:9745").unwrap();
492        assert_eq!(chain.name, "plasma");
493        assert_eq!(chain.chain_type, ChainType::Evm);
494        assert_eq!(chain.chain_id, "eip155:9745");
495    }
496
497    #[test]
498    fn test_parse_chain_unknown_evm_caip2() {
499        let chain = parse_chain("eip155:9746").unwrap();
500        assert_eq!(chain.name, "eip155:9746");
501        assert_eq!(chain.chain_type, ChainType::Evm);
502        assert_eq!(chain.chain_id, "eip155:9746");
503    }
504
505    #[test]
506    fn test_evm_chain_reference_for_known_chain() {
507        let chain = parse_chain("base").unwrap();
508        assert_eq!(chain.evm_chain_reference().unwrap(), "8453");
509        assert_eq!(chain.evm_chain_id_u64().unwrap(), 8453);
510    }
511
512    #[test]
513    fn test_evm_chain_reference_for_unknown_caip2_chain() {
514        let chain = parse_chain("eip155:999999").unwrap();
515        assert_eq!(chain.evm_chain_reference().unwrap(), "999999");
516        assert_eq!(chain.evm_chain_id_u64().unwrap(), 999999);
517    }
518
519    #[test]
520    fn test_evm_chain_reference_rejects_non_evm_chain() {
521        let chain = parse_chain("solana").unwrap();
522        let err = chain.evm_chain_reference().unwrap_err();
523        assert!(err.contains("not an EVM chain"));
524    }
525
526    #[test]
527    fn test_parse_chain_legacy_evm() {
528        let chain = parse_chain("evm").unwrap();
529        assert_eq!(chain.name, "ethereum");
530        assert_eq!(chain.chain_type, ChainType::Evm);
531    }
532
533    #[test]
534    fn test_parse_chain_solana() {
535        let chain = parse_chain("solana").unwrap();
536        assert_eq!(chain.chain_type, ChainType::Solana);
537    }
538
539    #[test]
540    fn test_parse_chain_xrpl() {
541        let chain = parse_chain("xrpl").unwrap();
542        assert_eq!(chain.chain_type, ChainType::Xrpl);
543        assert_eq!(chain.chain_id, "xrpl:mainnet");
544
545        let testnet = parse_chain("xrpl-testnet").unwrap();
546        assert_eq!(testnet.chain_type, ChainType::Xrpl);
547        assert_eq!(testnet.chain_id, "xrpl:testnet");
548
549        let devnet = parse_chain("xrpl-devnet").unwrap();
550        assert_eq!(devnet.chain_type, ChainType::Xrpl);
551        assert_eq!(devnet.chain_id, "xrpl:devnet");
552
553        // CAIP-2 IDs also accepted directly
554        let via_caip2 = parse_chain("xrpl:testnet").unwrap();
555        assert_eq!(via_caip2.chain_type, ChainType::Xrpl);
556        assert_eq!(via_caip2.chain_id, "xrpl:testnet");
557    }
558
559    #[test]
560    fn test_parse_chain_bare_numeric_known() {
561        // "8453" → Base (eip155:8453)
562        let chain = parse_chain("8453").unwrap();
563        assert_eq!(chain.name, "base");
564        assert_eq!(chain.chain_type, ChainType::Evm);
565        assert_eq!(chain.chain_id, "eip155:8453");
566    }
567
568    #[test]
569    fn test_parse_chain_bare_numeric_mainnet() {
570        let chain = parse_chain("1").unwrap();
571        assert_eq!(chain.name, "ethereum");
572        assert_eq!(chain.chain_id, "eip155:1");
573    }
574
575    #[test]
576    fn test_parse_chain_bare_numeric_unknown() {
577        // Unknown EVM chain ID still resolves
578        let chain = parse_chain("99999").unwrap();
579        assert_eq!(chain.chain_type, ChainType::Evm);
580        assert_eq!(chain.chain_id, "eip155:99999");
581    }
582
583    #[test]
584    fn test_parse_chain_unknown() {
585        assert!(parse_chain("unknown_chain").is_err());
586    }
587
588    #[test]
589    fn test_parse_chain_tempo_alias() {
590        let chain = parse_chain("tempo").unwrap();
591        assert_eq!(chain.name, "tempo");
592        assert_eq!(chain.chain_type, ChainType::Evm);
593        assert_eq!(chain.chain_id, "eip155:4217");
594    }
595
596    #[test]
597    fn test_parse_chain_tempo_caip2() {
598        let chain = parse_chain("eip155:4217").unwrap();
599        assert_eq!(chain.name, "tempo");
600        assert_eq!(chain.chain_type, ChainType::Evm);
601        assert_eq!(chain.chain_id, "eip155:4217");
602    }
603
604    #[test]
605    fn test_parse_chain_hyperliquid_alias() {
606        let chain = parse_chain("hyperliquid").unwrap();
607        assert_eq!(chain.name, "hyperliquid");
608        assert_eq!(chain.chain_type, ChainType::Evm);
609        assert_eq!(chain.chain_id, "eip155:999");
610    }
611
612    #[test]
613    fn test_parse_chain_hyperliquid_caip2() {
614        let chain = parse_chain("eip155:999").unwrap();
615        assert_eq!(chain.name, "hyperliquid");
616        assert_eq!(chain.chain_type, ChainType::Evm);
617        assert_eq!(chain.chain_id, "eip155:999");
618    }
619
620    #[test]
621    fn test_all_chain_types() {
622        assert_eq!(ALL_CHAIN_TYPES.len(), 10);
623    }
624
625    #[test]
626    fn test_default_chain_for_type() {
627        let chain = default_chain_for_type(ChainType::Evm);
628        assert_eq!(chain.name, "ethereum");
629        assert_eq!(chain.chain_id, "eip155:1");
630    }
631}