Skip to main content

kobe_core/
wallet.rs

1//! Unified wallet type for multi-chain key derivation.
2
3#[cfg(feature = "alloc")]
4use alloc::string::{String, ToString};
5
6use bip39::Mnemonic;
7use zeroize::Zeroizing;
8
9use crate::Error;
10
11/// A unified HD wallet that can derive keys for multiple cryptocurrencies.
12///
13/// This wallet holds a BIP39 mnemonic and derives a seed that can be used
14/// to generate addresses for Bitcoin, Ethereum, and other coins following
15/// BIP32/44/49/84 standards.
16///
17/// # Passphrase Support
18///
19/// The wallet supports an optional BIP39 passphrase (sometimes called "25th word").
20/// This provides an extra layer of security - the same mnemonic with different
21/// passphrases will produce completely different wallets.
22#[derive(Debug)]
23pub struct Wallet {
24    /// BIP39 mnemonic phrase.
25    mnemonic: Zeroizing<String>,
26    /// Seed derived from mnemonic + passphrase.
27    seed: Zeroizing<[u8; 64]>,
28    /// Whether a passphrase was used.
29    has_passphrase: bool,
30}
31
32impl Wallet {
33    /// Generate a new wallet with a random mnemonic.
34    ///
35    /// # Arguments
36    ///
37    /// * `word_count` - Number of words (12, 15, 18, 21, or 24)
38    /// * `passphrase` - Optional BIP39 passphrase for additional security
39    ///
40    /// # Errors
41    ///
42    /// Returns an error if the word count is invalid.
43    ///
44    /// # Note
45    ///
46    /// This function requires the `rand` feature to be enabled.
47    #[cfg(feature = "rand")]
48    pub fn generate(word_count: usize, passphrase: Option<&str>) -> Result<Self, Error> {
49        if !matches!(word_count, 12 | 15 | 18 | 21 | 24) {
50            return Err(Error::InvalidWordCount(word_count));
51        }
52
53        let mnemonic = Mnemonic::generate(word_count)?;
54        Self::from_mnemonic(mnemonic.to_string().as_str(), passphrase)
55    }
56
57    /// Create a wallet from raw entropy bytes.
58    ///
59    /// This is useful in `no_std` environments where you provide your own entropy
60    /// source instead of relying on the system RNG.
61    ///
62    /// # Arguments
63    ///
64    /// * `entropy` - Raw entropy bytes (16, 20, 24, 28, or 32 bytes for 12-24 words)
65    /// * `passphrase` - Optional BIP39 passphrase for additional security
66    ///
67    /// # Errors
68    ///
69    /// Returns an error if the entropy length is invalid.
70    pub fn from_entropy(entropy: &[u8], passphrase: Option<&str>) -> Result<Self, Error> {
71        let mnemonic = Mnemonic::from_entropy(entropy)?;
72        Self::from_mnemonic(mnemonic.to_string().as_str(), passphrase)
73    }
74
75    /// Create a wallet from an existing mnemonic phrase.
76    ///
77    /// # Arguments
78    ///
79    /// * `phrase` - BIP39 mnemonic phrase
80    /// * `passphrase` - Optional BIP39 passphrase
81    ///
82    /// # Errors
83    ///
84    /// Returns an error if the mnemonic is invalid.
85    pub fn from_mnemonic(phrase: &str, passphrase: Option<&str>) -> Result<Self, Error> {
86        let mnemonic: Mnemonic = phrase.parse()?;
87        let passphrase_str = passphrase.unwrap_or("");
88        let seed_bytes = mnemonic.to_seed(passphrase_str);
89
90        Ok(Self {
91            mnemonic: Zeroizing::new(mnemonic.to_string()),
92            seed: Zeroizing::new(seed_bytes),
93            has_passphrase: passphrase.is_some() && !passphrase_str.is_empty(),
94        })
95    }
96
97    /// Get the mnemonic phrase.
98    ///
99    /// **Security Warning**: Handle this value carefully as it can
100    /// reconstruct all derived keys.
101    #[inline]
102    #[must_use]
103    pub fn mnemonic(&self) -> &str {
104        &self.mnemonic
105    }
106
107    /// Get the seed bytes for key derivation.
108    ///
109    /// This seed can be used by chain-specific derivers (Bitcoin, Ethereum, etc.)
110    /// to generate addresses following their respective standards.
111    #[inline]
112    #[must_use]
113    pub fn seed(&self) -> &[u8; 64] {
114        &self.seed
115    }
116
117    /// Check if a passphrase was used to derive the seed.
118    #[must_use]
119    pub const fn has_passphrase(&self) -> bool {
120        self.has_passphrase
121    }
122
123    /// Get the word count of the mnemonic.
124    #[inline]
125    #[must_use]
126    pub fn word_count(&self) -> usize {
127        self.mnemonic.split_whitespace().count()
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
136
137    #[cfg(feature = "rand")]
138    #[test]
139    fn test_generate_12_words() {
140        let wallet = Wallet::generate(12, None).unwrap();
141        assert_eq!(wallet.word_count(), 12);
142        assert!(!wallet.has_passphrase());
143    }
144
145    #[cfg(feature = "rand")]
146    #[test]
147    fn test_generate_24_words() {
148        let wallet = Wallet::generate(24, None).unwrap();
149        assert_eq!(wallet.word_count(), 24);
150    }
151
152    #[cfg(feature = "rand")]
153    #[test]
154    fn test_generate_with_passphrase() {
155        let wallet = Wallet::generate(12, Some("secret")).unwrap();
156        assert!(wallet.has_passphrase());
157    }
158
159    #[test]
160    fn test_invalid_entropy_length() {
161        // 15 bytes is invalid (should be 16, 20, 24, 28, or 32)
162        let result = Wallet::from_entropy(&[0u8; 15], None);
163        assert!(result.is_err());
164    }
165
166    #[test]
167    fn test_from_entropy() {
168        // 16 bytes = 12 words
169        let entropy = [0u8; 16];
170        let wallet = Wallet::from_entropy(&entropy, None).unwrap();
171        assert_eq!(wallet.word_count(), 12);
172    }
173
174    #[test]
175    fn test_from_mnemonic() {
176        let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
177        assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
178    }
179
180    #[test]
181    fn test_passphrase_changes_seed() {
182        let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
183        let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("password")).unwrap();
184
185        // Same mnemonic with different passphrase should produce different seeds
186        assert_ne!(wallet1.seed(), wallet2.seed());
187    }
188
189    #[test]
190    fn test_deterministic_seed() {
191        let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
192        let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
193
194        // Same mnemonic + passphrase should produce identical seeds
195        assert_eq!(wallet1.seed(), wallet2.seed());
196    }
197}