alloy_signer_local/
mnemonic.rs

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