Skip to main content

kobe_btc/
deriver.rs

1//! Bitcoin address derivation from a unified wallet.
2
3#[cfg(feature = "alloc")]
4use alloc::{
5    string::{String, ToString},
6    vec::Vec,
7};
8use core::ops::Deref;
9
10use kobe_primitives::{
11    Derive, DeriveError, DerivedAccount, DerivedPublicKey, Wallet, derive_range,
12};
13use zeroize::Zeroizing;
14
15use crate::address::create_address;
16use crate::wif::encode_wif;
17use crate::{AddressType, DerivationPath, Network};
18
19/// Bitcoin address deriver from a unified wallet seed.
20#[derive(Debug)]
21pub struct Deriver<'a> {
22    wallet: &'a Wallet,
23    network: Network,
24}
25
26/// Bitcoin-specific derived account: unified [`DerivedAccount`] plus WIF,
27/// address type, and structured path.
28#[derive(Clone)]
29pub struct BtcAccount {
30    inner: DerivedAccount,
31    private_key_wif: Zeroizing<String>,
32    address_type: AddressType,
33    bip32_path: DerivationPath,
34}
35
36impl core::fmt::Debug for BtcAccount {
37    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
38        f.debug_struct("BtcAccount")
39            .field("inner", &self.inner)
40            .field("private_key_wif", &"[REDACTED]")
41            .field("address_type", &self.address_type)
42            .field("bip32_path", &self.bip32_path)
43            .finish()
44    }
45}
46
47impl BtcAccount {
48    /// Private key in WIF, zeroized on drop.
49    #[inline]
50    #[must_use]
51    pub const fn private_key_wif(&self) -> &Zeroizing<String> {
52        &self.private_key_wif
53    }
54
55    /// Address type used for this account.
56    #[inline]
57    #[must_use]
58    pub const fn address_type(&self) -> AddressType {
59        self.address_type
60    }
61
62    /// Structured BIP-32 path.
63    #[inline]
64    #[must_use]
65    pub const fn bip32_path(&self) -> &DerivationPath {
66        &self.bip32_path
67    }
68
69    /// Borrow the unified account.
70    #[inline]
71    #[must_use]
72    pub const fn as_derived_account(&self) -> &DerivedAccount {
73        &self.inner
74    }
75
76    /// Consume into the unified account.
77    #[inline]
78    #[must_use]
79    pub fn into_derived_account(self) -> DerivedAccount {
80        self.inner
81    }
82}
83
84impl Deref for BtcAccount {
85    type Target = DerivedAccount;
86
87    #[inline]
88    fn deref(&self) -> &Self::Target {
89        &self.inner
90    }
91}
92
93impl From<BtcAccount> for DerivedAccount {
94    #[inline]
95    fn from(btc: BtcAccount) -> Self {
96        btc.inner
97    }
98}
99
100impl AsRef<DerivedAccount> for BtcAccount {
101    #[inline]
102    fn as_ref(&self) -> &DerivedAccount {
103        &self.inner
104    }
105}
106
107impl<'a> Deriver<'a> {
108    /// Create a deriver. Key material is not derived until a `derive_*` call.
109    ///
110    /// Infallible, matching other chain crates (`Deriver::new(wallet)`).
111    #[inline]
112    #[must_use]
113    pub const fn new(wallet: &'a Wallet, network: Network) -> Self {
114        Self { wallet, network }
115    }
116
117    /// Default: P2WPKH at BIP-84.
118    ///
119    /// # Errors
120    ///
121    /// Returns an error if path, key, address, or WIF derivation fails.
122    #[inline]
123    pub fn derive(&self, index: u32) -> Result<BtcAccount, DeriveError> {
124        self.derive_with(AddressType::P2wpkh, index)
125    }
126
127    /// Derive with an explicit address type (standard purpose path).
128    ///
129    /// # Errors
130    ///
131    /// Returns an error if path, key, address, or WIF derivation fails.
132    #[inline]
133    pub fn derive_with(
134        &self,
135        address_type: AddressType,
136        index: u32,
137    ) -> Result<BtcAccount, DeriveError> {
138        let path = DerivationPath::bip_standard(address_type, self.network, 0, false, index)?;
139        self.derive_structured(&path, address_type)
140    }
141
142    /// Derive a contiguous index range.
143    ///
144    /// # Errors
145    ///
146    /// Returns an error if the range is invalid or any account derivation fails.
147    pub fn derive_many_with(
148        &self,
149        address_type: AddressType,
150        start: u32,
151        count: u32,
152    ) -> Result<Vec<BtcAccount>, DeriveError> {
153        derive_range(start, count, |i| self.derive_with(address_type, i))
154    }
155
156    /// Derive at a path string; infer [`AddressType`] from purpose.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error if the path is invalid or account derivation fails.
161    pub fn derive_at(&self, path: &str) -> Result<BtcAccount, DeriveError> {
162        let parsed = DerivationPath::from_path_str(path)?;
163        let address_type = infer_address_type(&parsed).ok_or_else(|| {
164            DeriveError::Path(alloc::format!(
165                "btc: cannot infer address type from path '{path}'; \
166                 purpose must be 44'/49'/84'/86'. \
167                 Use Deriver::derive_at_with(path, address_type) for custom paths."
168            ))
169        })?;
170        self.derive_structured(&parsed, address_type)
171    }
172
173    /// Derive at a path with an explicit address type (non-standard paths).
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if the path is invalid or account derivation fails.
178    pub fn derive_at_with(
179        &self,
180        path: &str,
181        address_type: AddressType,
182    ) -> Result<BtcAccount, DeriveError> {
183        let parsed = DerivationPath::from_path_str(path)?;
184        self.derive_structured(&parsed, address_type)
185    }
186
187    /// Low-level entry: pre-parsed path + address type.
188    ///
189    /// # Errors
190    ///
191    /// Returns an error if key, address, or WIF derivation fails.
192    pub fn derive_structured(
193        &self,
194        path: &DerivationPath,
195        address_type: AddressType,
196    ) -> Result<BtcAccount, DeriveError> {
197        let path_string = path.to_string();
198        let derived = self.wallet.derive_secp256k1(&path_string)?;
199
200        // Secrets remain Zeroizing end-to-end.
201        let sk = derived.private_key_bytes();
202        let pk = derived.compressed_pubkey();
203
204        let address = create_address(&pk, self.network, address_type)?;
205        let private_key_wif = encode_wif(&sk, self.network)?;
206
207        let inner = DerivedAccount::new(
208            path_string,
209            sk,
210            DerivedPublicKey::Secp256k1Compressed(pk),
211            address,
212        );
213
214        Ok(BtcAccount {
215            inner,
216            private_key_wif,
217            address_type,
218            bip32_path: path.clone(),
219        })
220    }
221
222    /// Network this deriver was created with.
223    #[must_use]
224    pub const fn network(&self) -> Network {
225        self.network
226    }
227}
228
229impl Derive for Deriver<'_> {
230    type Account = BtcAccount;
231    type Error = DeriveError;
232
233    fn derive(&self, index: u32) -> Result<BtcAccount, DeriveError> {
234        self.derive_with(AddressType::P2wpkh, index)
235    }
236
237    fn derive_path(&self, path: &str) -> Result<BtcAccount, DeriveError> {
238        self.derive_at(path)
239    }
240}
241
242fn infer_address_type(path: &DerivationPath) -> Option<AddressType> {
243    let first = path.first_segment()?;
244    if first.is_hardened() {
245        AddressType::from_purpose(first.index())
246    } else {
247        None
248    }
249}
250
251#[cfg(test)]
252mod tests {
253    use kobe_primitives::DeriveExt;
254
255    use super::*;
256    use crate::wif::decode_wif;
257
258    const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
259
260    fn test_wallet() -> Wallet {
261        Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap()
262    }
263
264    fn deriver(wallet: &Wallet, network: Network) -> Deriver<'_> {
265        Deriver::new(wallet, network)
266    }
267
268    #[test]
269    fn debug_redacts_wif() {
270        let wallet = test_wallet();
271        let account = deriver(&wallet, Network::Mainnet).derive(0).unwrap();
272        let wif = account.private_key_wif().as_str().to_owned();
273        let dbg = alloc::format!("{account:?}");
274        assert!(dbg.contains("[REDACTED]"));
275        assert!(!dbg.contains(&wif), "Debug must not leak WIF: {dbg}");
276    }
277
278    #[test]
279    fn derived_key_bytes_match_bip84_vector() {
280        let wallet = test_wallet();
281        let d = deriver(&wallet, Network::Mainnet);
282        let account = d.derive_at("m/84'/0'/0'/0/0").unwrap();
283        assert_eq!(
284            account.private_key_hex().as_str(),
285            "4604b4b710fe91f584fff084e1a9159fe4f8408fff380596a604948474ce4fa3"
286        );
287        assert_eq!(
288            account.public_key_hex(),
289            "0330d54fd0dd420a6e5f8d3624f5f3482cae350f79d5f0753bf5beef9c2d91af3c"
290        );
291    }
292
293    #[test]
294    fn kat_bip84_p2wpkh_abandon_index0() {
295        let wallet = test_wallet();
296        let a = deriver(&wallet, Network::Mainnet)
297            .derive_with(AddressType::P2wpkh, 0)
298            .unwrap();
299        assert_eq!(a.path(), "m/84'/0'/0'/0/0");
300        assert_eq!(a.address(), "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu");
301        assert_eq!(a.address_type(), AddressType::P2wpkh);
302        assert_eq!(
303            a.private_key_hex().as_str(),
304            "4604b4b710fe91f584fff084e1a9159fe4f8408fff380596a604948474ce4fa3"
305        );
306    }
307
308    #[test]
309    fn kat_bip84_p2wpkh_abandon_index1() {
310        let wallet = test_wallet();
311        let a = deriver(&wallet, Network::Mainnet)
312            .derive_with(AddressType::P2wpkh, 1)
313            .unwrap();
314        assert_eq!(a.path(), "m/84'/0'/0'/0/1");
315        assert_eq!(a.address(), "bc1qnjg0jd8228aq7egyzacy8cys3knf9xvrerkf9g");
316    }
317
318    #[test]
319    fn kat_bip44_p2pkh_abandon_index0() {
320        let wallet = test_wallet();
321        let a = deriver(&wallet, Network::Mainnet)
322            .derive_with(AddressType::P2pkh, 0)
323            .unwrap();
324        assert_eq!(a.path(), "m/44'/0'/0'/0/0");
325        assert_eq!(a.address(), "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA");
326    }
327
328    #[test]
329    fn kat_bip49_p2sh_p2wpkh_abandon_index0() {
330        let wallet = test_wallet();
331        let a = deriver(&wallet, Network::Mainnet)
332            .derive_with(AddressType::P2shP2wpkh, 0)
333            .unwrap();
334        assert_eq!(a.path(), "m/49'/0'/0'/0/0");
335        assert_eq!(a.address(), "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf");
336    }
337
338    #[test]
339    fn kat_bip86_p2tr_abandon_index0() {
340        let wallet = test_wallet();
341        let a = deriver(&wallet, Network::Mainnet)
342            .derive_with(AddressType::P2tr, 0)
343            .unwrap();
344        assert_eq!(a.path(), "m/86'/0'/0'/0/0");
345        assert_eq!(
346            a.address(),
347            "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr"
348        );
349    }
350
351    /// BIP-86 mainnet vector index 1:
352    /// <https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki>
353    #[test]
354    fn kat_bip86_p2tr_abandon_index1() {
355        let wallet = test_wallet();
356        let a = deriver(&wallet, Network::Mainnet)
357            .derive_with(AddressType::P2tr, 1)
358            .unwrap();
359        assert_eq!(a.path(), "m/86'/0'/0'/0/1");
360        assert_eq!(
361            a.address(),
362            "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh"
363        );
364    }
365
366    #[test]
367    fn kat_testnet_p2pkh_abandon_index0() {
368        let wallet = test_wallet();
369        let a = deriver(&wallet, Network::Testnet)
370            .derive_with(AddressType::P2pkh, 0)
371            .unwrap();
372        assert_eq!(a.path(), "m/44'/1'/0'/0/0");
373        assert_eq!(a.address(), "mkpZhYtJu2r87Js3pDiWJDmPte2NRZ8bJV");
374    }
375
376    /// BIP-49 testnet vector:
377    /// <https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki>
378    #[test]
379    fn kat_testnet_p2sh_p2wpkh_abandon_index0() {
380        let wallet = test_wallet();
381        let a = deriver(&wallet, Network::Testnet)
382            .derive_with(AddressType::P2shP2wpkh, 0)
383            .unwrap();
384        assert_eq!(a.path(), "m/49'/1'/0'/0/0");
385        assert_eq!(a.address(), "2Mww8dCYPUpKHofjgcXcBCEGmniw9CoaiD2");
386    }
387
388    #[test]
389    fn kat_testnet_p2wpkh_abandon_index0() {
390        let wallet = test_wallet();
391        let a = deriver(&wallet, Network::Testnet)
392            .derive_with(AddressType::P2wpkh, 0)
393            .unwrap();
394        assert_eq!(a.path(), "m/84'/1'/0'/0/0");
395        assert_eq!(a.address(), "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl");
396    }
397
398    #[test]
399    fn kat_testnet_p2tr_abandon_index0() {
400        let wallet = test_wallet();
401        let a = deriver(&wallet, Network::Testnet)
402            .derive_with(AddressType::P2tr, 0)
403            .unwrap();
404        assert_eq!(a.path(), "m/86'/1'/0'/0/0");
405        assert_eq!(
406            a.address(),
407            "tb1p8wpt9v4frpf3tkn0srd97pksgsxc5hs52lafxwru9kgeephvs7rqlqt9zj"
408        );
409    }
410
411    #[test]
412    fn default_derive_uses_bip84_p2wpkh() {
413        let wallet = test_wallet();
414        let d = deriver(&wallet, Network::Mainnet);
415        let def = d.derive(0).unwrap();
416        let explicit = d.derive_with(AddressType::P2wpkh, 0).unwrap();
417        assert_eq!(def.address(), explicit.address());
418        assert_eq!(def.path(), explicit.path());
419    }
420
421    #[test]
422    fn derive_many_matches_individual() {
423        let wallet = test_wallet();
424        let d = deriver(&wallet, Network::Mainnet);
425        let batch = d.derive_many(0, 5).unwrap();
426        let single: Vec<_> = (0..5)
427            .map(|i| d.derive_with(AddressType::P2wpkh, i).unwrap())
428            .collect();
429        for (b, s) in batch.iter().zip(single.iter()) {
430            assert_eq!(b.address(), s.address());
431            assert_eq!(b.path(), s.path());
432        }
433    }
434
435    #[test]
436    fn wif_roundtrips_to_private_key_bytes() {
437        let wallet = test_wallet();
438        let a = deriver(&wallet, Network::Mainnet).derive(0).unwrap();
439        let (key, network) = decode_wif(a.private_key_wif().as_str()).unwrap();
440        assert_eq!(key.as_ref(), a.private_key_bytes().as_ref());
441        assert_eq!(network, Network::Mainnet);
442    }
443
444    #[test]
445    fn wif_roundtrips_testnet_private_key_bytes() {
446        let wallet = test_wallet();
447        let a = deriver(&wallet, Network::Testnet).derive(0).unwrap();
448        let (key, network) = decode_wif(a.private_key_wif().as_str()).unwrap();
449        assert_eq!(key.as_ref(), a.private_key_bytes().as_ref());
450        assert_eq!(network, Network::Testnet);
451    }
452
453    #[test]
454    fn passphrase_changes_derivation() {
455        let wallet = test_wallet();
456        let with_pass = Wallet::from_mnemonic(TEST_MNEMONIC, Some("TREZOR")).unwrap();
457        assert_ne!(
458            deriver(&wallet, Network::Mainnet)
459                .derive(0)
460                .unwrap()
461                .address(),
462            deriver(&with_pass, Network::Mainnet)
463                .derive(0)
464                .unwrap()
465                .address(),
466        );
467    }
468
469    #[test]
470    fn derive_path_infers_address_type_from_purpose() {
471        let wallet = test_wallet();
472        let d = deriver(&wallet, Network::Mainnet);
473
474        let legacy = d.derive_path("m/44'/0'/0'/0/0").unwrap();
475        assert_eq!(legacy.address_type(), AddressType::P2pkh);
476        assert!(legacy.address().starts_with('1'));
477
478        let nested = d.derive_path("m/49'/0'/0'/0/0").unwrap();
479        assert_eq!(nested.address_type(), AddressType::P2shP2wpkh);
480        assert!(nested.address().starts_with('3'));
481
482        let native = d.derive_path("m/84'/0'/0'/0/0").unwrap();
483        assert_eq!(native.address_type(), AddressType::P2wpkh);
484        assert!(native.address().starts_with("bc1q"));
485
486        let taproot = d.derive_path("m/86'/0'/0'/0/0").unwrap();
487        assert_eq!(taproot.address_type(), AddressType::P2tr);
488        assert!(taproot.address().starts_with("bc1p"));
489    }
490
491    #[test]
492    fn derive_path_rejects_non_standard_purpose() {
493        let wallet = test_wallet();
494        let d = deriver(&wallet, Network::Mainnet);
495
496        let err = d.derive_path("m/1'/2'/3'").unwrap_err();
497        assert!(matches!(err, DeriveError::Path(_)));
498        if let DeriveError::Path(msg) = &err {
499            assert!(msg.contains("cannot infer address type"));
500            assert!(msg.contains("derive_at_with"));
501        }
502
503        let non_hardened_err = d.derive_path("m/44/0'/0'/0/0").unwrap_err();
504        assert!(matches!(non_hardened_err, DeriveError::Path(_)));
505    }
506
507    #[test]
508    fn derive_at_matches_trait_derive_path() {
509        let wallet = test_wallet();
510        let d = deriver(&wallet, Network::Mainnet);
511        for path in [
512            "m/44'/0'/0'/0/0",
513            "m/49'/0'/0'/0/0",
514            "m/84'/0'/0'/0/0",
515            "m/86'/0'/0'/0/0",
516        ] {
517            let a = d.derive_path(path).unwrap();
518            let b = d.derive_at(path).unwrap();
519            assert_eq!(a.address(), b.address());
520            assert_eq!(a.address_type(), b.address_type());
521        }
522    }
523
524    #[test]
525    fn derive_at_with_accepts_non_standard_purpose() {
526        let wallet = test_wallet();
527        let d = deriver(&wallet, Network::Mainnet);
528
529        let acct = d
530            .derive_at_with("m/7'/0'/0'/0/0", AddressType::P2wpkh)
531            .unwrap();
532        assert_eq!(acct.path(), "m/7'/0'/0'/0/0");
533        assert_eq!(acct.address_type(), AddressType::P2wpkh);
534        assert!(acct.address().starts_with("bc1q"));
535
536        let override_acct = d
537            .derive_at_with("m/84'/0'/0'/0/0", AddressType::P2tr)
538            .unwrap();
539        assert_eq!(override_acct.address_type(), AddressType::P2tr);
540        assert!(override_acct.address().starts_with("bc1p"));
541    }
542
543    #[test]
544    fn derive_structured_matches_derive_at_with() {
545        let wallet = test_wallet();
546        let d = deriver(&wallet, Network::Mainnet);
547        let path_str = "m/84'/0'/0'/0/0";
548        let parsed = DerivationPath::from_path_str(path_str).unwrap();
549        let a = d.derive_structured(&parsed, AddressType::P2wpkh).unwrap();
550        let b = d.derive_at_with(path_str, AddressType::P2wpkh).unwrap();
551        assert_eq!(a.address(), b.address());
552        assert_eq!(a.path(), b.path());
553    }
554}