Skip to main content

kobe_btc/
types.rs

1//! Common types for Bitcoin wallet operations.
2
3#[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/// Bitcoin address types.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
20#[non_exhaustive]
21pub enum AddressType {
22    /// Pay to Public Key Hash (Legacy) — `1…` / `m…`/`n…`
23    P2pkh,
24    /// Pay to Script Hash wrapping P2WPKH (Nested `SegWit`) — `3…` / `2…`
25    P2shP2wpkh,
26    /// Pay to Witness Public Key Hash (Native `SegWit`) — `bc1q…` / `tb1q…`
27    #[default]
28    P2wpkh,
29    /// Pay to Taproot — `bc1p…` / `tb1p…`
30    P2tr,
31}
32
33impl AddressType {
34    /// BIP purpose for this address type.
35    #[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    /// Inverse of [`AddressType::purpose`].
47    #[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    /// Human-readable name.
60    #[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/// Error returned when parsing an invalid address type string.
79#[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/// One BIP-32 child index (hardened or normal).
107///
108/// Encapsulated so callers never depend on a third-party path type.
109#[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    /// Child index without the hardened high bit (always `< 2^31`).
119    #[inline]
120    #[must_use]
121    pub const fn index(self) -> u32 {
122        self.index
123    }
124
125    /// Whether this segment is hardened (`'` / `h`).
126    #[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/// BIP-32 derivation path with a stable `m/…` string form.
145///
146/// Validated and stored independently of any third-party BIP-32 crate so the
147/// public API does not leak `bip32::DerivationPath`.
148#[cfg(feature = "alloc")]
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct DerivationPath {
151    /// Canonical `m/…` form using `'` for hardened segments.
152    path: String,
153    segments: Vec<PathSegment>,
154}
155
156#[cfg(feature = "alloc")]
157impl DerivationPath {
158    /// Standard path: `m/purpose'/coin_type'/account'/change/index`.
159    ///
160    /// # Errors
161    ///
162    /// Returns [`DeriveError::Path`] if the generated path is invalid.
163    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    /// Parse a BIP-32 path string.
178    ///
179    /// Accepts hardened markers `'` or `h` / `H`. The display form always uses
180    /// `'`.
181    ///
182    /// # Errors
183    ///
184    /// - Empty / master-only path → [`DeriveError::Path`]
185    /// - Malformed path → [`DeriveError::Path`]
186    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            // BIP-32 child index occupies 31 bits; the high bit is the hardened flag.
231            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    /// Canonical `m/…` string (hardened as `'`).
258    #[inline]
259    #[must_use]
260    pub fn as_str(&self) -> &str {
261        &self.path
262    }
263
264    /// Borrow the path segments.
265    #[inline]
266    #[must_use]
267    pub fn segments(&self) -> &[PathSegment] {
268        &self.segments
269    }
270
271    /// First segment, if any (used to infer BIP purpose).
272    #[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}