1pub use hw_chain::{
2 Chain, ChainConfig, DEFAULT_BITCOIN_BIP32_PATH, DEFAULT_ETHEREUM_BIP32_PATH,
3 DEFAULT_SOLANA_BIP32_PATH,
4};
5
6use crate::bip32::{HARDENED, parse_bip32_path};
7use crate::error::{WalletError, WalletResult};
8
9pub trait ChainPathExt: Sized {
10 fn from_bip32_path(path: &[u32]) -> Option<Self>;
11 fn validate_signing_path(self, path: &[u32], operation: &str) -> WalletResult<()>;
12}
13
14impl ChainPathExt for Chain {
15 fn from_bip32_path(path: &[u32]) -> Option<Self> {
16 let coin_type = path.get(1).copied()? & !HARDENED;
17 Self::from_slip44(coin_type)
18 }
19
20 fn validate_signing_path(self, path: &[u32], operation: &str) -> WalletResult<()> {
21 let min_segments = match self {
23 Self::Solana => 2,
24 Self::Ethereum | Self::Bitcoin => 3,
25 };
26 if path.len() < min_segments {
27 return Err(WalletError::InvalidBip32Path(format!(
28 "{operation} path must contain at least {min_segments} segments for {self:?}"
29 )));
30 }
31
32 if let Some(inferred) = Self::from_bip32_path(path)
33 && inferred != self
34 {
35 return Err(WalletError::InvalidBip32Path(format!(
36 "chain/path mismatch: explicit {self:?} conflicts with inferred {inferred:?}"
37 )));
38 }
39
40 Ok(())
41 }
42}
43
44#[derive(Debug, Clone, Eq, PartialEq)]
45pub struct ResolvedDerivationPath {
46 pub chain: Chain,
47 pub path: String,
48 pub path_indices: Vec<u32>,
49}
50
51impl ResolvedDerivationPath {
52 pub fn resolve(
53 explicit_chain: Option<Chain>,
54 explicit_path: Option<&str>,
55 ) -> WalletResult<Self> {
56 let parsed_path = explicit_path
57 .map(|path| parse_bip32_path(path).map(|indices| (path.to_owned(), indices)))
58 .transpose()?;
59
60 let inferred_chain = parsed_path
61 .as_ref()
62 .and_then(|(_, indices)| Chain::from_bip32_path(indices));
63 let chain = explicit_chain.or(inferred_chain).unwrap_or(Chain::Ethereum);
64
65 if let (Some(explicit), Some(inferred), Some(path)) =
66 (explicit_chain, inferred_chain, explicit_path)
67 && explicit != inferred
68 {
69 return Err(WalletError::InvalidBip32Path(format!(
70 "chain/path mismatch: explicit {explicit:?} conflicts with inferred {inferred:?} from path '{path}'"
71 )));
72 }
73
74 let (path, path_indices) = match parsed_path {
75 Some(parsed) => parsed,
76 None => {
77 let default_path = chain.default_path().to_owned();
78 let path_indices = parse_bip32_path(&default_path)?;
79 (default_path, path_indices)
80 }
81 };
82
83 Ok(Self {
84 chain,
85 path,
86 path_indices,
87 })
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94
95 #[test]
96 fn infer_chain_from_coin_type() {
97 for (coin_type, expected) in [
98 (0x8000_003c, Chain::Ethereum),
99 (0x8000_0000, Chain::Bitcoin),
100 (0x8000_01f5, Chain::Solana),
101 ] {
102 assert_eq!(
103 Chain::from_bip32_path(&[0x8000_002c, coin_type]),
104 Some(expected)
105 );
106 }
107 }
108
109 #[test]
110 fn signing_path_minimum_length_is_chain_aware() {
111 let solana_root = [44 | HARDENED, 501 | HARDENED];
112 assert!(
113 Chain::Solana
114 .validate_signing_path(&solana_root, "sign")
115 .is_ok()
116 );
117 assert!(
118 Chain::Solana
119 .validate_signing_path(&solana_root[..1], "sign")
120 .is_err()
121 );
122 let eth_short = [44 | HARDENED, 60 | HARDENED];
123 assert!(
124 Chain::Ethereum
125 .validate_signing_path(ð_short, "sign")
126 .is_err()
127 );
128 }
129
130 #[test]
131 fn resolve_defaults_to_eth_when_empty() {
132 let resolved = ResolvedDerivationPath::resolve(None, None).expect("default resolution");
133 assert_eq!(resolved.chain, Chain::Ethereum);
134 assert_eq!(resolved.path, DEFAULT_ETHEREUM_BIP32_PATH);
135 }
136
137 #[test]
138 fn resolve_uses_chain_default_path() {
139 for chain in Chain::ALL {
140 let resolved =
141 ResolvedDerivationPath::resolve(Some(chain), None).expect("default path");
142 assert_eq!(resolved.chain, chain);
143 assert_eq!(resolved.path, chain.default_path());
144 }
145 }
146
147 #[test]
148 fn resolve_rejects_chain_path_mismatch() {
149 let err = ResolvedDerivationPath::resolve(
150 Some(Chain::Bitcoin),
151 Some(DEFAULT_ETHEREUM_BIP32_PATH),
152 )
153 .expect_err("mismatch should fail");
154 assert!(
155 err.to_string().contains("chain/path mismatch"),
156 "unexpected error: {err}"
157 );
158 }
159}