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    #[must_use]
102    pub fn mnemonic(&self) -> &str {
103        &self.mnemonic
104    }
105
106    /// Get the seed bytes for key derivation.
107    ///
108    /// This seed can be used by chain-specific derivers (Bitcoin, Ethereum, etc.)
109    /// to generate addresses following their respective standards.
110    #[must_use]
111    pub fn seed(&self) -> &[u8; 64] {
112        &self.seed
113    }
114
115    /// Check if a passphrase was used to derive the seed.
116    #[must_use]
117    pub const fn has_passphrase(&self) -> bool {
118        self.has_passphrase
119    }
120
121    /// Get the word count of the mnemonic.
122    #[must_use]
123    pub fn word_count(&self) -> usize {
124        self.mnemonic.split_whitespace().count()
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
133
134    #[cfg(feature = "rand")]
135    #[test]
136    fn test_generate_12_words() {
137        let wallet = Wallet::generate(12, None).unwrap();
138        assert_eq!(wallet.word_count(), 12);
139        assert!(!wallet.has_passphrase());
140    }
141
142    #[cfg(feature = "rand")]
143    #[test]
144    fn test_generate_24_words() {
145        let wallet = Wallet::generate(24, None).unwrap();
146        assert_eq!(wallet.word_count(), 24);
147    }
148
149    #[cfg(feature = "rand")]
150    #[test]
151    fn test_generate_with_passphrase() {
152        let wallet = Wallet::generate(12, Some("secret")).unwrap();
153        assert!(wallet.has_passphrase());
154    }
155
156    #[test]
157    fn test_invalid_entropy_length() {
158        // 15 bytes is invalid (should be 16, 20, 24, 28, or 32)
159        let result = Wallet::from_entropy(&[0u8; 15], None);
160        assert!(result.is_err());
161    }
162
163    #[test]
164    fn test_from_entropy() {
165        // 16 bytes = 12 words
166        let entropy = [0u8; 16];
167        let wallet = Wallet::from_entropy(&entropy, None).unwrap();
168        assert_eq!(wallet.word_count(), 12);
169    }
170
171    #[test]
172    fn test_from_mnemonic() {
173        let wallet = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
174        assert_eq!(wallet.mnemonic(), TEST_MNEMONIC);
175    }
176
177    #[test]
178    fn test_passphrase_changes_seed() {
179        let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, None).unwrap();
180        let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("password")).unwrap();
181
182        // Same mnemonic with different passphrase should produce different seeds
183        assert_ne!(wallet1.seed(), wallet2.seed());
184    }
185
186    #[test]
187    fn test_deterministic_seed() {
188        let wallet1 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
189        let wallet2 = Wallet::from_mnemonic(TEST_MNEMONIC, Some("test")).unwrap();
190
191        // Same mnemonic + passphrase should produce identical seeds
192        assert_eq!(wallet1.seed(), wallet2.seed());
193    }
194}