1#[cfg(feature = "alloc")]
4use alloc::{
5 format,
6 string::{String, ToString},
7 vec::Vec,
8};
9use core::fmt;
10use core::str::FromStr;
11
12#[cfg(feature = "alloc")]
13use kobe_primitives::DeriveError;
14
15#[cfg(feature = "alloc")]
16use crate::Network;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20#[non_exhaustive]
21pub enum AddressType {
22 P2pkh,
24 P2shP2wpkh,
26 #[default]
28 P2wpkh,
29 P2tr,
31}
32
33impl AddressType {
34 #[inline]
36 #[must_use]
37 pub const fn purpose(self) -> u32 {
38 match self {
39 Self::P2pkh => 44,
40 Self::P2shP2wpkh => 49,
41 Self::P2wpkh => 84,
42 Self::P2tr => 86,
43 }
44 }
45
46 #[inline]
48 #[must_use]
49 pub const fn from_purpose(purpose: u32) -> Option<Self> {
50 match purpose {
51 44 => Some(Self::P2pkh),
52 49 => Some(Self::P2shP2wpkh),
53 84 => Some(Self::P2wpkh),
54 86 => Some(Self::P2tr),
55 _ => None,
56 }
57 }
58
59 #[inline]
61 #[must_use]
62 pub const fn name(self) -> &'static str {
63 match self {
64 Self::P2pkh => "P2PKH (Legacy)",
65 Self::P2shP2wpkh => "P2SH-P2WPKH (SegWit)",
66 Self::P2wpkh => "P2WPKH (Native SegWit)",
67 Self::P2tr => "P2TR (Taproot)",
68 }
69 }
70}
71
72impl fmt::Display for AddressType {
73 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74 f.write_str(self.name())
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80#[non_exhaustive]
81pub struct ParseAddressTypeError;
82
83impl fmt::Display for ParseAddressTypeError {
84 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85 f.write_str("invalid address type, expected: p2pkh, p2sh, p2wpkh, or p2tr")
86 }
87}
88
89#[cfg(feature = "std")]
90impl std::error::Error for ParseAddressTypeError {}
91
92impl FromStr for AddressType {
93 type Err = ParseAddressTypeError;
94
95 fn from_str(s: &str) -> Result<Self, Self::Err> {
96 match s.to_lowercase().as_str() {
97 "p2pkh" | "legacy" => Ok(Self::P2pkh),
98 "p2sh" | "p2sh-p2wpkh" | "segwit" | "nested-segwit" => Ok(Self::P2shP2wpkh),
99 "p2wpkh" | "native-segwit" | "bech32" => Ok(Self::P2wpkh),
100 "p2tr" | "taproot" | "bech32m" => Ok(Self::P2tr),
101 _ => Err(ParseAddressTypeError),
102 }
103 }
104}
105
106#[cfg(feature = "alloc")]
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111pub struct PathSegment {
112 index: u32,
113 hardened: bool,
114}
115
116#[cfg(feature = "alloc")]
117impl PathSegment {
118 #[inline]
120 #[must_use]
121 pub const fn index(self) -> u32 {
122 self.index
123 }
124
125 #[inline]
127 #[must_use]
128 pub const fn is_hardened(self) -> bool {
129 self.hardened
130 }
131}
132
133#[cfg(feature = "alloc")]
134impl fmt::Display for PathSegment {
135 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136 write!(f, "{}", self.index)?;
137 if self.hardened {
138 f.write_str("'")?;
139 }
140 Ok(())
141 }
142}
143
144#[cfg(feature = "alloc")]
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct DerivationPath {
151 path: String,
153 segments: Vec<PathSegment>,
154}
155
156#[cfg(feature = "alloc")]
157impl DerivationPath {
158 pub fn bip_standard(
164 address_type: AddressType,
165 network: Network,
166 account: u32,
167 change: bool,
168 address_index: u32,
169 ) -> Result<Self, DeriveError> {
170 let purpose = address_type.purpose();
171 let coin_type = network.coin_type();
172 let change_val = u32::from(change);
173 let path_str = format!("m/{purpose}'/{coin_type}'/{account}'/{change_val}/{address_index}");
174 Self::from_path_str(&path_str)
175 }
176
177 pub fn from_path_str(path: &str) -> Result<Self, DeriveError> {
187 let trimmed = path.trim();
188 if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("m") {
189 return Err(DeriveError::Path(
190 "btc: derivation path must contain at least one segment".into(),
191 ));
192 }
193
194 let rest = trimmed
195 .strip_prefix('m')
196 .or_else(|| trimmed.strip_prefix('M'))
197 .ok_or_else(|| DeriveError::Path("btc: derivation path must start with 'm'".into()))?;
198
199 if rest.is_empty() {
200 return Err(DeriveError::Path(
201 "btc: derivation path must contain at least one segment".into(),
202 ));
203 }
204 if !rest.starts_with('/') {
205 return Err(DeriveError::Path(
206 "btc: derivation path segments must be separated by '/'".into(),
207 ));
208 }
209
210 let mut segments = Vec::new();
211 for raw in rest[1..].split('/') {
212 if raw.is_empty() {
213 return Err(DeriveError::Path(
214 "btc: empty derivation path segment".into(),
215 ));
216 }
217 let (num_part, hardened) = raw
218 .strip_suffix('\'')
219 .map(|n| (n, true))
220 .or_else(|| raw.strip_suffix(['h', 'H']).map(|n| (n, true)))
221 .unwrap_or((raw, false));
222 if num_part.is_empty() || !num_part.bytes().all(|b| b.is_ascii_digit()) {
223 return Err(DeriveError::Path(format!(
224 "btc: invalid derivation path segment '{raw}'"
225 )));
226 }
227 let index: u32 = num_part.parse().map_err(|_| {
228 DeriveError::Path(format!("btc: invalid derivation path index '{num_part}'"))
229 })?;
230 if index >= (1u32 << 31) {
232 return Err(DeriveError::Path(format!(
233 "btc: derivation path index out of range: {index}"
234 )));
235 }
236 segments.push(PathSegment { index, hardened });
237 }
238
239 if segments.is_empty() {
240 return Err(DeriveError::Path(
241 "btc: derivation path must contain at least one segment".into(),
242 ));
243 }
244
245 let mut canonical = String::from("m");
246 for seg in &segments {
247 canonical.push('/');
248 canonical.push_str(&seg.to_string());
249 }
250
251 Ok(Self {
252 path: canonical,
253 segments,
254 })
255 }
256
257 #[inline]
259 #[must_use]
260 pub fn as_str(&self) -> &str {
261 &self.path
262 }
263
264 #[inline]
266 #[must_use]
267 pub fn segments(&self) -> &[PathSegment] {
268 &self.segments
269 }
270
271 #[inline]
273 #[must_use]
274 pub fn first_segment(&self) -> Option<PathSegment> {
275 self.segments.first().copied()
276 }
277}
278
279#[cfg(feature = "alloc")]
280impl fmt::Display for DerivationPath {
281 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
282 f.write_str(&self.path)
283 }
284}
285
286#[cfg(feature = "alloc")]
287impl AsRef<str> for DerivationPath {
288 fn as_ref(&self) -> &str {
289 &self.path
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 #[test]
298 fn address_type_from_str() {
299 assert_eq!("p2pkh".parse::<AddressType>().unwrap(), AddressType::P2pkh);
300 assert_eq!("legacy".parse::<AddressType>().unwrap(), AddressType::P2pkh);
301 assert_eq!(
302 "p2sh".parse::<AddressType>().unwrap(),
303 AddressType::P2shP2wpkh
304 );
305 assert_eq!(
306 "p2wpkh".parse::<AddressType>().unwrap(),
307 AddressType::P2wpkh
308 );
309 assert_eq!("p2tr".parse::<AddressType>().unwrap(), AddressType::P2tr);
310 assert_eq!("taproot".parse::<AddressType>().unwrap(), AddressType::P2tr);
311 }
312
313 #[test]
314 fn address_type_purpose() {
315 assert_eq!(AddressType::P2pkh.purpose(), 44);
316 assert_eq!(AddressType::P2shP2wpkh.purpose(), 49);
317 assert_eq!(AddressType::P2wpkh.purpose(), 84);
318 assert_eq!(AddressType::P2tr.purpose(), 86);
319 }
320
321 #[test]
322 fn address_type_default() {
323 assert_eq!(AddressType::default(), AddressType::P2wpkh);
324 }
325
326 #[cfg(feature = "alloc")]
327 #[test]
328 fn derivation_path_display_keeps_master_prefix_and_tick() {
329 let path = DerivationPath::from_path_str("m/84'/0'/0'/0/0").unwrap();
330 assert_eq!(path.to_string(), "m/84'/0'/0'/0/0");
331 assert!(path.to_string().starts_with("m/"));
332 assert!(path.to_string().contains("84'"));
333 assert!(!path.to_string().contains("84h"));
334 }
335
336 #[cfg(feature = "alloc")]
337 #[test]
338 fn derivation_path_normalizes_h_suffix() {
339 let path = DerivationPath::from_path_str("m/84h/0h/0h/0/0").unwrap();
340 assert_eq!(path.to_string(), "m/84'/0'/0'/0/0");
341 assert_eq!(path.as_str(), "m/84'/0'/0'/0/0");
342 }
343
344 #[cfg(feature = "alloc")]
345 #[test]
346 fn derivation_path_rejects_empty_forms() {
347 for bad in ["", "m", "M", " m ", " m", "x/0", "m//0", "m/foo"] {
348 assert!(
349 matches!(
350 DerivationPath::from_path_str(bad),
351 Err(DeriveError::Path(_))
352 ),
353 "expected Path error for {bad:?}"
354 );
355 }
356 }
357
358 #[cfg(feature = "alloc")]
359 #[test]
360 fn first_segment_reports_hardened_purpose() {
361 let path = DerivationPath::from_path_str("m/84'/0'/0'/0/0").unwrap();
362 let first = path.first_segment().unwrap();
363 assert!(first.is_hardened());
364 assert_eq!(first.index(), 84);
365 }
366}