rustywallet_hd/
path.rs

1//! BIP32 derivation path parsing and manipulation.
2
3use crate::error::HdError;
4use std::fmt;
5use std::str::FromStr;
6
7/// Hardened derivation threshold (2^31).
8pub const HARDENED_BIT: u32 = 0x80000000;
9
10/// A single component in a derivation path.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ChildNumber {
13    /// Normal (non-hardened) derivation.
14    Normal(u32),
15    /// Hardened derivation.
16    Hardened(u32),
17}
18
19impl ChildNumber {
20    /// Create a normal child number.
21    pub fn normal(index: u32) -> Result<Self, HdError> {
22        if index >= HARDENED_BIT {
23            return Err(HdError::InvalidChildNumber(index));
24        }
25        Ok(ChildNumber::Normal(index))
26    }
27
28    /// Create a hardened child number.
29    pub fn hardened(index: u32) -> Result<Self, HdError> {
30        if index >= HARDENED_BIT {
31            return Err(HdError::InvalidChildNumber(index));
32        }
33        Ok(ChildNumber::Hardened(index))
34    }
35
36    /// Check if this is a hardened derivation.
37    pub fn is_hardened(&self) -> bool {
38        matches!(self, ChildNumber::Hardened(_))
39    }
40
41    /// Get the index value (without hardened bit).
42    pub fn index(&self) -> u32 {
43        match self {
44            ChildNumber::Normal(i) | ChildNumber::Hardened(i) => *i,
45        }
46    }
47
48    /// Get the raw value for derivation (with hardened bit if applicable).
49    pub fn raw_index(&self) -> u32 {
50        match self {
51            ChildNumber::Normal(i) => *i,
52            ChildNumber::Hardened(i) => i | HARDENED_BIT,
53        }
54    }
55}
56
57impl fmt::Display for ChildNumber {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        match self {
60            ChildNumber::Normal(i) => write!(f, "{}", i),
61            ChildNumber::Hardened(i) => write!(f, "{}'", i),
62        }
63    }
64}
65
66/// BIP32 derivation path.
67///
68/// # Example
69///
70/// ```
71/// use rustywallet_hd::DerivationPath;
72///
73/// // Parse a BIP44 Bitcoin path
74/// let path = DerivationPath::parse("m/44'/0'/0'/0/0").unwrap();
75/// assert_eq!(path.to_string(), "m/44'/0'/0'/0/0");
76///
77/// // Use helper for BIP44 Bitcoin
78/// let btc_path = DerivationPath::bip44_bitcoin(0, 0, 0);
79/// assert_eq!(btc_path.to_string(), "m/44'/0'/0'/0/0");
80/// ```
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct DerivationPath {
83    components: Vec<ChildNumber>,
84}
85
86impl DerivationPath {
87    /// Create an empty path (master key).
88    pub fn master() -> Self {
89        Self { components: vec![] }
90    }
91
92    /// Parse a derivation path from string.
93    ///
94    /// Supports both `'` and `h` notation for hardened derivation.
95    pub fn parse(path: &str) -> Result<Self, HdError> {
96        let path = path.trim();
97
98        // Handle empty or just "m"
99        if path.is_empty() || path == "m" || path == "M" {
100            return Ok(Self::master());
101        }
102
103        // Must start with m/ or M/
104        let path = if path.starts_with("m/") || path.starts_with("M/") {
105            &path[2..]
106        } else {
107            return Err(HdError::InvalidPath(
108                "Path must start with 'm/'".to_string(),
109            ));
110        };
111
112        let mut components = Vec::new();
113
114        for part in path.split('/') {
115            let part = part.trim();
116            if part.is_empty() {
117                continue;
118            }
119
120            let (index_str, hardened) = if part.ends_with('\'') || part.ends_with('h') || part.ends_with('H') {
121                (&part[..part.len() - 1], true)
122            } else {
123                (part, false)
124            };
125
126            let index: u32 = index_str.parse().map_err(|_| {
127                HdError::InvalidPath(format!("Invalid index: {}", index_str))
128            })?;
129
130            if index >= HARDENED_BIT {
131                return Err(HdError::InvalidPath(format!(
132                    "Index too large: {}",
133                    index
134                )));
135            }
136
137            let child = if hardened {
138                ChildNumber::Hardened(index)
139            } else {
140                ChildNumber::Normal(index)
141            };
142
143            components.push(child);
144        }
145
146        Ok(Self { components })
147    }
148
149    /// Create BIP44 path for Bitcoin: m/44'/0'/account'/change/index
150    pub fn bip44_bitcoin(account: u32, change: u32, index: u32) -> Self {
151        Self {
152            components: vec![
153                ChildNumber::Hardened(44),
154                ChildNumber::Hardened(0), // Bitcoin coin type
155                ChildNumber::Hardened(account),
156                ChildNumber::Normal(change),
157                ChildNumber::Normal(index),
158            ],
159        }
160    }
161
162    /// Create BIP44 path for Ethereum: m/44'/60'/account'/0/index
163    pub fn bip44_ethereum(account: u32, index: u32) -> Self {
164        Self {
165            components: vec![
166                ChildNumber::Hardened(44),
167                ChildNumber::Hardened(60), // Ethereum coin type
168                ChildNumber::Hardened(account),
169                ChildNumber::Normal(0),
170                ChildNumber::Normal(index),
171            ],
172        }
173    }
174
175    /// Get path components.
176    pub fn components(&self) -> &[ChildNumber] {
177        &self.components
178    }
179
180    /// Check if path contains any hardened derivation.
181    pub fn has_hardened(&self) -> bool {
182        self.components.iter().any(|c| c.is_hardened())
183    }
184
185    /// Get the depth (number of components).
186    pub fn depth(&self) -> u8 {
187        self.components.len() as u8
188    }
189
190    /// Append a child number to the path.
191    pub fn child(&self, child: ChildNumber) -> Self {
192        let mut components = self.components.clone();
193        components.push(child);
194        Self { components }
195    }
196}
197
198impl fmt::Display for DerivationPath {
199    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
200        write!(f, "m")?;
201        for component in &self.components {
202            write!(f, "/{}", component)?;
203        }
204        Ok(())
205    }
206}
207
208impl FromStr for DerivationPath {
209    type Err = HdError;
210
211    fn from_str(s: &str) -> Result<Self, Self::Err> {
212        Self::parse(s)
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn test_parse_master() {
222        let path = DerivationPath::parse("m").unwrap();
223        assert!(path.components().is_empty());
224    }
225
226    #[test]
227    fn test_parse_bip44() {
228        let path = DerivationPath::parse("m/44'/0'/0'/0/0").unwrap();
229        assert_eq!(path.components().len(), 5);
230        assert!(path.components()[0].is_hardened());
231        assert!(path.components()[1].is_hardened());
232        assert!(path.components()[2].is_hardened());
233        assert!(!path.components()[3].is_hardened());
234        assert!(!path.components()[4].is_hardened());
235    }
236
237    #[test]
238    fn test_parse_h_notation() {
239        let path = DerivationPath::parse("m/44h/0h/0h/0/0").unwrap();
240        assert_eq!(path.to_string(), "m/44'/0'/0'/0/0");
241    }
242
243    #[test]
244    fn test_bip44_bitcoin() {
245        let path = DerivationPath::bip44_bitcoin(0, 0, 0);
246        assert_eq!(path.to_string(), "m/44'/0'/0'/0/0");
247    }
248
249    #[test]
250    fn test_bip44_ethereum() {
251        let path = DerivationPath::bip44_ethereum(0, 0);
252        assert_eq!(path.to_string(), "m/44'/60'/0'/0/0");
253    }
254
255    #[test]
256    fn test_roundtrip() {
257        let original = "m/44'/0'/0'/0/0";
258        let path = DerivationPath::parse(original).unwrap();
259        assert_eq!(path.to_string(), original);
260    }
261
262    #[test]
263    fn test_has_hardened() {
264        let path1 = DerivationPath::parse("m/44'/0'/0'/0/0").unwrap();
265        assert!(path1.has_hardened());
266
267        let path2 = DerivationPath::parse("m/0/1/2").unwrap();
268        assert!(!path2.has_hardened());
269    }
270}