ethers_signers/wallet/
mnemonic.rs

1//! Specific helper functions for creating/loading a mnemonic private key following BIP-39
2//! specifications
3use crate::{Wallet, WalletError};
4
5use coins_bip32::path::DerivationPath;
6use coins_bip39::{Mnemonic, Wordlist};
7use ethers_core::{
8    k256::ecdsa::SigningKey,
9    types::PathOrString,
10    utils::{secret_key_to_address, to_checksum},
11};
12use rand::Rng;
13use std::{fs::File, io::Write, marker::PhantomData, path::PathBuf, str::FromStr};
14use thiserror::Error;
15
16const DEFAULT_DERIVATION_PATH_PREFIX: &str = "m/44'/60'/0'/0/";
17
18/// Represents a structure that can resolve into a `Wallet<SigningKey>`.
19#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct MnemonicBuilder<W: Wordlist> {
21    /// The mnemonic phrase can be supplied to the builder as a string or a path to the file whose
22    /// contents are the phrase. A builder that has a valid phrase should `build` the wallet.
23    phrase: Option<PathOrString>,
24    /// The mnemonic builder can also be asked to generate a new random wallet by providing the
25    /// number of words in the phrase. By default this is set to 12.
26    word_count: usize,
27    /// The derivation path at which the extended private key child will be derived at. By default
28    /// the mnemonic builder uses the path: "m/44'/60'/0'/0/0".
29    derivation_path: DerivationPath,
30    /// Optional password for the mnemonic phrase.
31    password: Option<String>,
32    /// Optional field that if enabled, writes the mnemonic phrase to disk storage at the provided
33    /// path.
34    write_to: Option<PathBuf>,
35    /// PhantomData
36    _wordlist: PhantomData<W>,
37}
38
39/// Error produced by the mnemonic wallet module
40#[derive(Error, Debug)]
41pub enum MnemonicBuilderError {
42    /// Error suggests that a phrase (path or words) was expected but not found
43    #[error("Expected phrase not found")]
44    ExpectedPhraseNotFound,
45    /// Error suggests that a phrase (path or words) was not expected but found
46    #[error("Unexpected phrase found")]
47    UnexpectedPhraseFound,
48}
49
50impl<W: Wordlist> Default for MnemonicBuilder<W> {
51    fn default() -> Self {
52        Self {
53            phrase: None,
54            word_count: 12usize,
55            derivation_path: DerivationPath::from_str(&format!(
56                "{}{}",
57                DEFAULT_DERIVATION_PATH_PREFIX, 0
58            ))
59            .expect("should parse the default derivation path"),
60            password: None,
61            write_to: None,
62            _wordlist: PhantomData,
63        }
64    }
65}
66
67impl<W: Wordlist> MnemonicBuilder<W> {
68    /// Sets the phrase in the mnemonic builder. The phrase can either be a string or a path to
69    /// the file that contains the phrase. Once a phrase is provided, the key will be generated
70    /// deterministically by calling the `build` method.
71    ///
72    /// # Example
73    ///
74    /// ```
75    /// use ethers_signers::{MnemonicBuilder, coins_bip39::English};
76    /// # async fn foo() -> Result<(), Box<dyn std::error::Error>> {
77    ///
78    /// let wallet = MnemonicBuilder::<English>::default()
79    ///     .phrase("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
80    ///     .build()?;
81    ///
82    /// # Ok(())
83    /// # }
84    /// ```
85    #[must_use]
86    pub fn phrase<P: Into<PathOrString>>(mut self, phrase: P) -> Self {
87        self.phrase = Some(phrase.into());
88        self
89    }
90
91    /// Sets the word count of a mnemonic phrase to be generated at random. If the `phrase` field
92    /// is set, then `word_count` will be ignored.
93    ///
94    /// # Example
95    ///
96    /// ```
97    /// use ethers_signers::{MnemonicBuilder, coins_bip39::English};
98    /// # async fn foo() -> Result<(), Box<dyn std::error::Error>> {
99    ///
100    /// let mut rng = rand::thread_rng();
101    /// let wallet = MnemonicBuilder::<English>::default()
102    ///     .word_count(24)
103    ///     .build_random(&mut rng)?;
104    ///
105    /// # Ok(())
106    /// # }
107    /// ```
108    #[must_use]
109    pub fn word_count(mut self, count: usize) -> Self {
110        self.word_count = count;
111        self
112    }
113
114    /// Sets the derivation path of the child key to be derived. The derivation path is calculated
115    /// using the default derivation path prefix used in Ethereum, i.e. "m/44'/60'/0'/0/{index}".
116    pub fn index<U: Into<u32>>(mut self, index: U) -> Result<Self, WalletError> {
117        self.derivation_path = DerivationPath::from_str(&format!(
118            "{}{}",
119            DEFAULT_DERIVATION_PATH_PREFIX,
120            index.into()
121        ))?;
122        Ok(self)
123    }
124
125    /// Sets the derivation path of the child key to be derived.
126    pub fn derivation_path(mut self, path: &str) -> Result<Self, WalletError> {
127        self.derivation_path = DerivationPath::from_str(path)?;
128        Ok(self)
129    }
130
131    /// Sets the password used to construct the seed from the mnemonic phrase.
132    #[must_use]
133    pub fn password(mut self, password: &str) -> Self {
134        self.password = Some(password.to_string());
135        self
136    }
137
138    /// Sets the path to which the randomly generated phrase will be written to. This field is
139    /// ignored when building a wallet from the provided mnemonic phrase.
140    #[must_use]
141    pub fn write_to<P: Into<PathBuf>>(mut self, path: P) -> Self {
142        self.write_to = Some(path.into());
143        self
144    }
145
146    /// Builds a `LocalWallet` using the parameters set in mnemonic builder. This method expects
147    /// the phrase field to be set.
148    pub fn build(&self) -> Result<Wallet<SigningKey>, WalletError> {
149        let mnemonic = match &self.phrase {
150            Some(path_or_string) => {
151                let phrase = path_or_string.read()?;
152                Mnemonic::<W>::new_from_phrase(&phrase)?
153            }
154            None => return Err(MnemonicBuilderError::ExpectedPhraseNotFound.into()),
155        };
156        self.mnemonic_to_wallet(&mnemonic)
157    }
158
159    /// Builds a `LocalWallet` using the parameters set in the mnemonic builder and constructing
160    /// the phrase using the provided random number generator.
161    pub fn build_random<R: Rng>(&self, rng: &mut R) -> Result<Wallet<SigningKey>, WalletError> {
162        let mnemonic = match &self.phrase {
163            None => Mnemonic::<W>::new_with_count(rng, self.word_count)?,
164            _ => return Err(MnemonicBuilderError::UnexpectedPhraseFound.into()),
165        };
166        let wallet = self.mnemonic_to_wallet(&mnemonic)?;
167
168        // Write the mnemonic phrase to storage if a directory has been provided.
169        if let Some(dir) = &self.write_to {
170            let mut file = File::create(dir.as_path().join(to_checksum(&wallet.address, None)))?;
171            file.write_all(mnemonic.to_phrase().as_bytes())?;
172        }
173
174        Ok(wallet)
175    }
176
177    fn mnemonic_to_wallet(
178        &self,
179        mnemonic: &Mnemonic<W>,
180    ) -> Result<Wallet<SigningKey>, WalletError> {
181        let derived_priv_key =
182            mnemonic.derive_key(&self.derivation_path, self.password.as_deref())?;
183        let key: &coins_bip32::prelude::SigningKey = derived_priv_key.as_ref();
184        let signer = SigningKey::from_bytes(&key.to_bytes())?;
185        let address = secret_key_to_address(&signer);
186
187        Ok(Wallet::<SigningKey> { signer, address, chain_id: 1 })
188    }
189}
190
191#[cfg(test)]
192#[cfg(not(target_arch = "wasm32"))]
193mod tests {
194    use super::*;
195
196    use crate::coins_bip39::English;
197    use tempfile::tempdir;
198
199    const TEST_DERIVATION_PATH: &str = "m/44'/60'/0'/2/1";
200
201    #[tokio::test]
202    async fn mnemonic_deterministic() {
203        // Testcases have been taken from MyCryptoWallet
204        const TESTCASES: [(&str, u32, Option<&str>, &str); 4] = [
205            (
206                "work man father plunge mystery proud hollow address reunion sauce theory bonus",
207                0u32,
208                Some("TREZOR123"),
209                "0x431a00DA1D54c281AeF638A73121B3D153e0b0F6",
210            ),
211            (
212                "inject danger program federal spice bitter term garbage coyote breeze thought funny",
213                1u32,
214                Some("LEDGER321"),
215                "0x231a3D0a05d13FAf93078C779FeeD3752ea1350C",
216            ),
217            (
218                "fire evolve buddy tenant talent favorite ankle stem regret myth dream fresh",
219                2u32,
220                None,
221                "0x1D86AD5eBb2380dAdEAF52f61f4F428C485460E9",
222            ),
223            (
224                "thumb soda tape crunch maple fresh imitate cancel order blind denial giraffe",
225                3u32,
226                None,
227                "0xFB78b25f69A8e941036fEE2A5EeAf349D81D4ccc",
228            ),
229        ];
230        TESTCASES.iter().for_each(|(phrase, index, password, expected_addr)| {
231            let wallet = match password {
232                Some(psswd) => MnemonicBuilder::<English>::default()
233                    .phrase(*phrase)
234                    .index(*index)
235                    .unwrap()
236                    .password(psswd)
237                    .build()
238                    .unwrap(),
239                None => MnemonicBuilder::<English>::default()
240                    .phrase(*phrase)
241                    .index(*index)
242                    .unwrap()
243                    .build()
244                    .unwrap(),
245            };
246            assert_eq!(&to_checksum(&wallet.address, None), expected_addr);
247        })
248    }
249
250    #[tokio::test]
251    async fn mnemonic_write_read() {
252        let dir = tempdir().unwrap();
253
254        // Construct a wallet from random mnemonic phrase and write it to the temp dir.
255        let mut rng = rand::thread_rng();
256        let wallet1 = MnemonicBuilder::<English>::default()
257            .word_count(24)
258            .derivation_path(TEST_DERIVATION_PATH)
259            .unwrap()
260            .write_to(dir.as_ref())
261            .build_random(&mut rng)
262            .unwrap();
263
264        // Ensure that only one file has been created.
265        let paths = std::fs::read_dir(dir.as_ref()).unwrap();
266        assert_eq!(paths.count(), 1);
267
268        // Use the newly created file's path to instantiate wallet.
269        let phrase_path = dir.as_ref().join(to_checksum(&wallet1.address, None));
270        let wallet2 = MnemonicBuilder::<English>::default()
271            .phrase(phrase_path.to_str().unwrap())
272            .derivation_path(TEST_DERIVATION_PATH)
273            .unwrap()
274            .build()
275            .unwrap();
276
277        // Ensure that both wallets belong to the same address.
278        assert_eq!(wallet1.address, wallet2.address);
279
280        dir.close().unwrap();
281    }
282}