kobe-btc 1.1.1

Bitcoin HD wallet derivation for Kobe
Documentation
//! Bitcoin address derivation from a unified wallet.

#[cfg(feature = "alloc")]
use alloc::{
    string::{String, ToString},
    vec::Vec,
};
use core::marker::PhantomData;
use core::ops::Deref;

use bitcoin::PrivateKey;
use bitcoin::bip32::Xpriv;
use bitcoin::key::CompressedPublicKey;
use bitcoin::secp256k1::Secp256k1;
use kobe_primitives::{Derive, DerivedAccount, Wallet, derive_range};
use zeroize::Zeroizing;

use crate::address::create_address;
use crate::{AddressType, DerivationPath, DeriveError, Network};

/// Bitcoin address deriver from a unified wallet seed.
///
/// This deriver takes a seed from [`kobe_primitives::Wallet`] and derives
/// Bitcoin addresses following BIP32/44/49/84 standards.
#[derive(Debug)]
pub struct Deriver<'a> {
    /// Master extended private key.
    master_key: Xpriv,
    /// Cached secp256k1 context (~768KB, reused across derivations).
    secp: Secp256k1<bitcoin::secp256k1::All>,
    /// Bitcoin network (mainnet or testnet).
    network: Network,
    /// Phantom data to track wallet lifetime.
    _wallet: PhantomData<&'a Wallet>,
}

/// A Bitcoin-specific derived account — [`DerivedAccount`] plus chain-specific metadata.
///
/// Wraps the unified [`DerivedAccount`] (path, 32-byte private key, 33-byte
/// compressed public key, address string) and adds Bitcoin-only fields:
/// [`WIF`](Self::private_key_wif), [`AddressType`](Self::address_type), and
/// the structured [`DerivationPath`](Self::bip32_path).
///
/// Implements `Deref<Target = DerivedAccount>`, so all shared accessors
/// (`address()`, `public_key_bytes()`, etc.) are available directly.
#[derive(Debug, Clone)]
pub struct BtcAccount {
    inner: DerivedAccount,
    private_key_wif: Zeroizing<String>,
    address_type: AddressType,
    bip32_path: DerivationPath,
}

impl BtcAccount {
    /// Private key in WIF (Wallet Import Format), zeroized on drop.
    #[inline]
    #[must_use]
    pub const fn private_key_wif(&self) -> &Zeroizing<String> {
        &self.private_key_wif
    }

    /// The [`AddressType`] used to derive this account.
    #[inline]
    #[must_use]
    pub const fn address_type(&self) -> AddressType {
        self.address_type
    }

    /// Structured BIP-32 derivation path.
    ///
    /// Access the string form via [`DerivedAccount::path`](Self::path)
    /// (inherited through `Deref`).
    #[inline]
    #[must_use]
    pub const fn bip32_path(&self) -> &DerivationPath {
        &self.bip32_path
    }

    /// The underlying unified [`DerivedAccount`].
    #[inline]
    #[must_use]
    pub const fn as_derived_account(&self) -> &DerivedAccount {
        &self.inner
    }

    /// Consume and yield the underlying [`DerivedAccount`], dropping
    /// Bitcoin-specific metadata.
    #[inline]
    #[must_use]
    pub fn into_derived_account(self) -> DerivedAccount {
        self.inner
    }
}

impl Deref for BtcAccount {
    type Target = DerivedAccount;

    #[inline]
    fn deref(&self) -> &Self::Target {
        &self.inner
    }
}

impl From<BtcAccount> for DerivedAccount {
    #[inline]
    fn from(btc: BtcAccount) -> Self {
        btc.inner
    }
}

impl<'a> Deriver<'a> {
    /// Create a new Bitcoin deriver from a wallet.
    ///
    /// # Errors
    ///
    /// Returns an error if the master key derivation fails.
    #[inline]
    pub fn new(wallet: &'a Wallet, network: Network) -> Result<Self, DeriveError> {
        let master_key = Xpriv::new_master(network.to_bitcoin_network(), wallet.seed().as_slice())?;

        Ok(Self {
            master_key,
            secp: Secp256k1::new(),
            network,
            _wallet: PhantomData,
        })
    }

    /// Derive a Bitcoin account using P2WPKH (Native `SegWit`) by default.
    ///
    /// Uses path: `m/84'/0'/0'/0/{index}` for mainnet.
    ///
    /// # Errors
    ///
    /// Returns an error if derivation fails.
    #[inline]
    pub fn derive(&self, index: u32) -> Result<BtcAccount, DeriveError> {
        self.derive_with(AddressType::P2wpkh, index)
    }

    /// Derive a Bitcoin account with a specific [`AddressType`].
    ///
    /// This method supports all four Bitcoin address formats:
    /// - **P2pkh** (Legacy): `m/44'/coin'/0'/0/{index}`
    /// - **`P2shP2wpkh`** (Nested SegWit): `m/49'/coin'/0'/0/{index}`
    /// - **P2wpkh** (Native SegWit): `m/84'/coin'/0'/0/{index}`
    /// - **P2tr** (Taproot): `m/86'/coin'/0'/0/{index}`
    ///
    /// # Errors
    ///
    /// Returns an error if derivation fails.
    #[inline]
    pub fn derive_with(
        &self,
        address_type: AddressType,
        index: u32,
    ) -> Result<BtcAccount, DeriveError> {
        let path = DerivationPath::bip_standard(address_type, self.network, 0, false, index)?;
        self.derive_bip32_path(&path, address_type)
    }

    /// Derive multiple accounts using P2WPKH (Native `SegWit`) by default.
    ///
    /// # Errors
    ///
    /// Returns an error if any derivation fails.
    #[inline]
    pub fn derive_many(&self, start: u32, count: u32) -> Result<Vec<BtcAccount>, DeriveError> {
        self.derive_many_with(AddressType::P2wpkh, start, count)
    }

    /// Derive multiple accounts with a specific [`AddressType`].
    ///
    /// # Errors
    ///
    /// Returns an error if any derivation fails.
    pub fn derive_many_with(
        &self,
        address_type: AddressType,
        start: u32,
        count: u32,
    ) -> Result<Vec<BtcAccount>, DeriveError> {
        derive_range(start, count, |i| self.derive_with(address_type, i))
    }

    /// Derive a [`BtcAccount`] at a structured [`DerivationPath`] with the
    /// requested [`AddressType`].
    ///
    /// Lowest-level derivation method; all higher-level entry points funnel
    /// through this.
    ///
    /// # Errors
    ///
    /// Returns an error if derivation fails.
    pub fn derive_bip32_path(
        &self,
        path: &DerivationPath,
        address_type: AddressType,
    ) -> Result<BtcAccount, DeriveError> {
        let derived = self.master_key.derive_priv(&self.secp, path.inner())?;

        let private_key = PrivateKey::new(derived.private_key, self.network.to_bitcoin_network());
        let public_key = CompressedPublicKey::from_private_key(&self.secp, &private_key)
            .map_err(|_| DeriveError::InvalidPrivateKey)?;

        let address = create_address(&public_key, self.network, address_type);

        let sk_bytes = Zeroizing::new(derived.private_key.secret_bytes());
        let pk_bytes = public_key.to_bytes();

        let inner = DerivedAccount::new(
            path.to_string(),
            sk_bytes,
            pk_bytes.to_vec(),
            address.to_string(),
        );

        Ok(BtcAccount {
            inner,
            private_key_wif: Zeroizing::new(private_key.to_wif()),
            address_type,
            bip32_path: path.clone(),
        })
    }

    /// Get the network.
    #[must_use]
    pub const fn network(&self) -> Network {
        self.network
    }
}

impl Derive for Deriver<'_> {
    type Error = DeriveError;

    fn derive(&self, index: u32) -> Result<DerivedAccount, DeriveError> {
        Ok(self
            .derive_with(AddressType::P2wpkh, index)?
            .into_derived_account())
    }

    fn derive_path(&self, path: &str) -> Result<DerivedAccount, DeriveError> {
        let parsed = DerivationPath::from_path_str(path)?;
        Ok(self
            .derive_bip32_path(&parsed, AddressType::P2wpkh)?
            .into_derived_account())
    }
}

#[cfg(test)]
mod tests {
    use bitcoin::PrivateKey;

    use super::*;

    /// Canonical BIP-39 test mnemonic (12 × `abandon` + `about`).
    ///
    /// Mnemonic and derived addresses appear on iancoleman.io/bip39, every
    /// hardware wallet vendor test, and the official BIP-84 / BIP-86 test
    /// vectors, so any regression will be immediately obvious to users.
    const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";

    fn test_wallet() -> Wallet {
        Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap()
    }

    fn deriver(wallet: &Wallet, network: Network) -> Deriver<'_> {
        Deriver::new(wallet, network).unwrap()
    }

    /// BIP-84 (`m/84'/0'/0'/0/{i}`) → native-`SegWit` `bc1q…` addresses.
    /// Cross-verified against `bitcoinjs-lib@p2wpkh` in an independent
    /// Node.js pipeline, matching the BIP-84 test vectors listed at
    /// <https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki>.
    #[test]
    fn kat_bip84_p2wpkh_abandon_index0() {
        let w = test_wallet();
        let a = deriver(&w, Network::Mainnet)
            .derive_with(AddressType::P2wpkh, 0)
            .unwrap();
        assert_eq!(a.path(), "m/84'/0'/0'/0/0");
        assert_eq!(a.address(), "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu");
        assert_eq!(a.address_type(), AddressType::P2wpkh);
        assert_eq!(
            a.private_key_hex().as_str(),
            "4604b4b710fe91f584fff084e1a9159fe4f8408fff380596a604948474ce4fa3"
        );
    }

    #[test]
    fn kat_bip84_p2wpkh_abandon_index1() {
        let w = test_wallet();
        let a = deriver(&w, Network::Mainnet)
            .derive_with(AddressType::P2wpkh, 1)
            .unwrap();
        assert_eq!(a.path(), "m/84'/0'/0'/0/1");
        assert_eq!(a.address(), "bc1qnjg0jd8228aq7egyzacy8cys3knf9xvrerkf9g");
    }

    /// BIP-44 (`m/44'/0'/0'/0/{i}`) → legacy P2PKH `1…` addresses.
    #[test]
    fn kat_bip44_p2pkh_abandon_index0() {
        let w = test_wallet();
        let a = deriver(&w, Network::Mainnet)
            .derive_with(AddressType::P2pkh, 0)
            .unwrap();
        assert_eq!(a.path(), "m/44'/0'/0'/0/0");
        assert_eq!(a.address(), "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA");
    }

    /// BIP-49 (`m/49'/0'/0'/0/{i}`) → P2SH-wrapped `SegWit` `3…` addresses.
    #[test]
    fn kat_bip49_p2sh_p2wpkh_abandon_index0() {
        let w = test_wallet();
        let a = deriver(&w, Network::Mainnet)
            .derive_with(AddressType::P2shP2wpkh, 0)
            .unwrap();
        assert_eq!(a.path(), "m/49'/0'/0'/0/0");
        assert_eq!(a.address(), "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf");
    }

    /// BIP-86 (`m/86'/0'/0'/0/{i}`) → single-key Taproot `bc1p…`
    /// addresses. Cross-verified against `bitcoinjs-lib@p2tr`.
    #[test]
    fn kat_bip86_p2tr_abandon_index0() {
        let w = test_wallet();
        let a = deriver(&w, Network::Mainnet)
            .derive_with(AddressType::P2tr, 0)
            .unwrap();
        assert_eq!(a.path(), "m/86'/0'/0'/0/0");
        assert_eq!(
            a.address(),
            "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr"
        );
    }

    /// Testnet SLIP-44 coin type `1` + BIP-84 bech32 HRP `tb`.
    /// Cross-verified against `bitcoinjs-lib` on `bitcoin.networks.testnet`.
    #[test]
    fn kat_testnet_p2wpkh_abandon_index0() {
        let w = test_wallet();
        let a = deriver(&w, Network::Testnet)
            .derive_with(AddressType::P2wpkh, 0)
            .unwrap();
        assert_eq!(a.path(), "m/84'/1'/0'/0/0");
        assert_eq!(a.address(), "tb1q6rz28mcfaxtmd6v789l9rrlrusdprr9pqcpvkl");
    }

    /// `Derive::derive` (the trait) and `Deriver::derive` (inherent) must
    /// both route to P2WPKH with BIP-84 paths.
    #[test]
    fn default_derive_uses_bip84_p2wpkh() {
        let w = test_wallet();
        let d = deriver(&w, Network::Mainnet);
        let def = d.derive(0).unwrap();
        let explicit = d.derive_with(AddressType::P2wpkh, 0).unwrap();
        assert_eq!(def.address(), explicit.address());
        assert_eq!(def.path(), explicit.path());
    }

    /// `derive_many` must agree with scalar `derive_with` for every index.
    #[test]
    fn derive_many_matches_individual() {
        let w = test_wallet();
        let d = deriver(&w, Network::Mainnet);
        let batch = d.derive_many(0, 5).unwrap();
        let single: Vec<_> = (0..5)
            .map(|i| d.derive_with(AddressType::P2wpkh, i).unwrap())
            .collect();
        for (b, s) in batch.iter().zip(single.iter()) {
            assert_eq!(b.address(), s.address());
            assert_eq!(b.path(), s.path());
        }
    }

    /// WIF must round-trip back to the same 32-byte private key — guards
    /// against checksum or version-byte errors in the WIF encoder.
    #[test]
    fn wif_roundtrips_to_private_key_bytes() {
        let w = test_wallet();
        let a = deriver(&w, Network::Mainnet).derive(0).unwrap();
        let pk = PrivateKey::from_wif(a.private_key_wif().as_str()).unwrap();
        assert_eq!(&pk.to_bytes(), a.private_key_bytes().as_slice());
        assert_eq!(pk.network, bitcoin::NetworkKind::Main);
    }

    #[test]
    fn passphrase_changes_derivation() {
        let w = Wallet::from_mnemonic(TEST_MNEMONIC, Some("TREZOR")).unwrap();
        assert_ne!(
            deriver(&test_wallet(), Network::Mainnet)
                .derive(0)
                .unwrap()
                .address(),
            deriver(&w, Network::Mainnet).derive(0).unwrap().address(),
        );
    }
}