Skip to main content

kobe_core/
wallet.rs

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