feat: mnemonic phrase support for wallet (#256)
* feat: mnemonic phrase support for wallet * refactor: better error handling and clippy linting * fix: derive from path and tests * chore: renamed package coins-bip39 * refactor: convenient builder API to setup mnemonic wallet * refactor: re-export coins-bip39 for convenience * clippy: fix warnings for multiple complex types in provider * feat: randomly generated mnemonic phrase can be written to storage
This commit is contained in:
parent
32b4e9e3f5
commit
79862ffda5
File diff suppressed because it is too large
Load Diff
|
@ -13,6 +13,9 @@ pub use transaction::{Transaction, TransactionReceipt, TransactionRequest};
|
|||
mod address_or_bytes;
|
||||
pub use address_or_bytes::AddressOrBytes;
|
||||
|
||||
mod path_or_string;
|
||||
pub use path_or_string::PathOrString;
|
||||
|
||||
mod i256;
|
||||
pub use i256::I256;
|
||||
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A type that can either be a `Path` or a `String`
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum PathOrString {
|
||||
/// A path type
|
||||
Path(PathBuf),
|
||||
/// A string type
|
||||
String(String),
|
||||
}
|
||||
|
||||
impl From<PathBuf> for PathOrString {
|
||||
fn from(p: PathBuf) -> Self {
|
||||
PathOrString::Path(p)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for PathOrString {
|
||||
fn from(s: &str) -> Self {
|
||||
let path = Path::new(s);
|
||||
if path.exists() {
|
||||
PathOrString::Path(path.to_owned())
|
||||
} else {
|
||||
PathOrString::String(s.to_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PathOrString {
|
||||
/// Reads the contents at path, or simply returns the string.
|
||||
pub fn read(&self) -> Result<String, std::io::Error> {
|
||||
match self {
|
||||
PathOrString::Path(pathbuf) => std::fs::read_to_string(pathbuf),
|
||||
PathOrString::String(s) => Ok(s.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
#![cfg_attr(docsrs, feature(doc_cfg))]
|
||||
#![deny(broken_intra_doc_links)]
|
||||
#![allow(clippy::type_complexity)]
|
||||
//! # Clients for interacting with Ethereum nodes
|
||||
//!
|
||||
//! This crate provides asynchronous [Ethereum JSON-RPC](https://github.com/ethereum/wiki/wiki/JSON-RPC)
|
||||
|
|
|
@ -16,7 +16,8 @@ rustdoc-args = ["--cfg", "docsrs"]
|
|||
[dependencies]
|
||||
ethers-core = { version = "0.2.2", path = "../ethers-core" }
|
||||
thiserror = { version = "1.0.24", default-features = false }
|
||||
|
||||
coins-bip32 = "0.2.2"
|
||||
coins-bip39 = "0.2.2"
|
||||
coins-ledger = { version = "0.1.0", default-features = false, optional = true }
|
||||
eth-keystore = { version = "0.2.0" }
|
||||
hex = { version = "0.4.3", default-features = false, features = ["std"] }
|
||||
|
|
|
@ -39,7 +39,10 @@
|
|||
//! [`Transaction`]: ethers_core::types::Transaction
|
||||
//! [`TransactionRequest`]: ethers_core::types::TransactionRequest
|
||||
mod wallet;
|
||||
pub use wallet::Wallet;
|
||||
pub use wallet::{MnemonicBuilder, Wallet, WalletError};
|
||||
|
||||
/// Re-export the BIP-32 crate so that wordlists can be accessed conveniently.
|
||||
pub use coins_bip39;
|
||||
|
||||
/// A wallet instantiated with a locally stored private key
|
||||
pub type LocalWallet = Wallet<ethers_core::k256::ecdsa::SigningKey>;
|
||||
|
|
|
@ -0,0 +1,279 @@
|
|||
//! Specific helper functions for creating/loading a mnemonic private key following BIP-39
|
||||
//! specifications
|
||||
use crate::{wallet::util::key_to_address, Wallet, WalletError};
|
||||
|
||||
use coins_bip32::path::DerivationPath;
|
||||
use coins_bip39::{Mnemonic, Wordlist};
|
||||
use ethers_core::{k256::ecdsa::SigningKey, types::PathOrString, utils::to_checksum};
|
||||
use rand::Rng;
|
||||
use std::{fs::File, io::Write, marker::PhantomData, path::PathBuf, str::FromStr};
|
||||
use thiserror::Error;
|
||||
|
||||
const DEFAULT_DERIVATION_PATH_PREFIX: &str = "m/44'/60'/0'/0/";
|
||||
|
||||
/// Represents a structure that can resolve into a `Wallet<SigningKey>`.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MnemonicBuilder<W: Wordlist> {
|
||||
/// The mnemonic phrase can be supplied to the builder as a string or a path to the file whose
|
||||
/// contents are the phrase. A builder that has a valid phrase should `build` the wallet.
|
||||
phrase: Option<PathOrString>,
|
||||
/// The mnemonic builder can also be asked to generate a new random wallet by providing the
|
||||
/// number of words in the phrase. By default this is set to 12.
|
||||
word_count: usize,
|
||||
/// The derivation path at which the extended private key child will be derived at. By default
|
||||
/// the mnemonic builder uses the path: "m/44'/60'/0'/0/0".
|
||||
derivation_path: DerivationPath,
|
||||
/// Optional password for the mnemonic phrase.
|
||||
password: Option<String>,
|
||||
/// Optional field that if enabled, writes the mnemonic phrase to disk storage at the provided
|
||||
/// path.
|
||||
write_to: Option<PathBuf>,
|
||||
/// PhantomData
|
||||
_wordlist: PhantomData<W>,
|
||||
}
|
||||
|
||||
/// Error produced by the mnemonic wallet module
|
||||
#[derive(Error, Debug)]
|
||||
pub enum MnemonicBuilderError {
|
||||
/// Error suggests that a phrase (path or words) was expected but not found
|
||||
#[error("Expected phrase not found")]
|
||||
ExpectedPhraseNotFound,
|
||||
/// Error suggests that a phrase (path or words) was not expected but found
|
||||
#[error("Unexpected phrase found")]
|
||||
UnexpectedPhraseFound,
|
||||
}
|
||||
|
||||
impl<W: Wordlist> Default for MnemonicBuilder<W> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phrase: None,
|
||||
word_count: 12usize,
|
||||
derivation_path: DerivationPath::from_str(&format!(
|
||||
"{}{}",
|
||||
DEFAULT_DERIVATION_PATH_PREFIX, 0
|
||||
))
|
||||
.expect("should parse the default derivation path"),
|
||||
password: None,
|
||||
write_to: None,
|
||||
_wordlist: PhantomData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<W: Wordlist> MnemonicBuilder<W> {
|
||||
/// Sets the phrase in the mnemonic builder. The phrase can either be a string or a path to
|
||||
/// the file that contains the phrase. Once a phrase is provided, the key will be generated
|
||||
/// deterministically by calling the `build` method.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use ethers_signers::{MnemonicBuilder, coins_bip39::English};
|
||||
/// # async fn foo() -> Result<(), Box<dyn std::error::Error>> {
|
||||
///
|
||||
/// let wallet = MnemonicBuilder::<English>::default()
|
||||
/// .phrase("abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about")
|
||||
/// .build()?;
|
||||
///
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn phrase<P: Into<PathOrString>>(mut self, phrase: P) -> Self {
|
||||
self.phrase = Some(phrase.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the word count of a mnemonic phrase to be generated at random. If the `phrase` field
|
||||
/// is set, then `word_count` will be ignored.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// use ethers_signers::{MnemonicBuilder, coins_bip39::English};
|
||||
/// # async fn foo() -> Result<(), Box<dyn std::error::Error>> {
|
||||
///
|
||||
/// let mut rng = rand::thread_rng();
|
||||
/// let wallet = MnemonicBuilder::<English>::default()
|
||||
/// .word_count(24)
|
||||
/// .build_random(&mut rng)?;
|
||||
///
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
/// ```
|
||||
pub fn word_count(mut self, count: usize) -> Self {
|
||||
self.word_count = count;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the derivation path of the child key to be derived. The derivation path is calculated
|
||||
/// using the default derivation path prefix used in Ethereum, i.e. "m/44'/60'/0'/0/{index}".
|
||||
pub fn index<U: Into<u32>>(mut self, index: U) -> Result<Self, WalletError> {
|
||||
self.derivation_path = DerivationPath::from_str(&format!(
|
||||
"{}{}",
|
||||
DEFAULT_DERIVATION_PATH_PREFIX,
|
||||
index.into()
|
||||
))?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Sets the derivation path of the child key to be derived.
|
||||
pub fn derivation_path(mut self, path: &str) -> Result<Self, WalletError> {
|
||||
self.derivation_path = DerivationPath::from_str(path)?;
|
||||
Ok(self)
|
||||
}
|
||||
|
||||
/// Sets the password used to construct the seed from the mnemonic phrase.
|
||||
pub fn password(mut self, password: &str) -> Self {
|
||||
self.password = Some(password.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the path to which the randomly generated phrase will be written to. This field is
|
||||
/// ignored when building a wallet from the provided mnemonic phrase.
|
||||
pub fn write_to<P: Into<PathBuf>>(mut self, path: P) -> Self {
|
||||
self.write_to = Some(path.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Builds a `LocalWallet` using the parameters set in mnemonic builder. This method expects
|
||||
/// the phrase field to be set.
|
||||
pub fn build(&self) -> Result<Wallet<SigningKey>, WalletError> {
|
||||
let mnemonic = match &self.phrase {
|
||||
Some(path_or_string) => {
|
||||
let phrase = path_or_string.read()?;
|
||||
Mnemonic::<W>::new_from_phrase(&phrase)?
|
||||
}
|
||||
None => return Err(MnemonicBuilderError::ExpectedPhraseNotFound.into()),
|
||||
};
|
||||
self.mnemonic_to_wallet(&mnemonic)
|
||||
}
|
||||
|
||||
/// Builds a `LocalWallet` using the parameters set in the mnemonic builder and constructing
|
||||
/// the phrase using the provided random number generator.
|
||||
pub fn build_random<R: Rng>(&self, rng: &mut R) -> Result<Wallet<SigningKey>, WalletError> {
|
||||
let mnemonic = match &self.phrase {
|
||||
None => Mnemonic::<W>::new_with_count(rng, self.word_count)?,
|
||||
_ => return Err(MnemonicBuilderError::UnexpectedPhraseFound.into()),
|
||||
};
|
||||
let wallet = self.mnemonic_to_wallet(&mnemonic)?;
|
||||
|
||||
// Write the mnemonic phrase to storage if a directory has been provided.
|
||||
if let Some(dir) = &self.write_to {
|
||||
let mut file = File::create(dir.as_path().join(to_checksum(&wallet.address, None)))?;
|
||||
file.write_all(mnemonic.to_phrase()?.as_bytes())?;
|
||||
}
|
||||
|
||||
Ok(wallet)
|
||||
}
|
||||
|
||||
fn mnemonic_to_wallet(
|
||||
&self,
|
||||
mnemonic: &Mnemonic<W>,
|
||||
) -> Result<Wallet<SigningKey>, WalletError> {
|
||||
let derived_priv_key =
|
||||
mnemonic.derive_key(&self.derivation_path, self.password.as_deref())?;
|
||||
let key: &SigningKey = derived_priv_key.as_ref();
|
||||
let signer = SigningKey::from_bytes(&key.to_bytes())?;
|
||||
let address = key_to_address(&signer);
|
||||
|
||||
Ok(Wallet::<SigningKey> {
|
||||
signer,
|
||||
address,
|
||||
chain_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use crate::coins_bip39::English;
|
||||
use tempfile::tempdir;
|
||||
|
||||
const TEST_DERIVATION_PATH: &str = "m/44'/60'/0'/2/1";
|
||||
|
||||
#[tokio::test]
|
||||
async fn mnemonic_deterministic() {
|
||||
// Testcases have been taken from MyCryptoWallet
|
||||
const TESTCASES: [(&str, u32, Option<&str>, &str); 4] = [
|
||||
(
|
||||
"work man father plunge mystery proud hollow address reunion sauce theory bonus",
|
||||
0u32,
|
||||
Some("TREZOR123"),
|
||||
"0x431a00DA1D54c281AeF638A73121B3D153e0b0F6",
|
||||
),
|
||||
(
|
||||
"inject danger program federal spice bitter term garbage coyote breeze thought funny",
|
||||
1u32,
|
||||
Some("LEDGER321"),
|
||||
"0x231a3D0a05d13FAf93078C779FeeD3752ea1350C",
|
||||
),
|
||||
(
|
||||
"fire evolve buddy tenant talent favorite ankle stem regret myth dream fresh",
|
||||
2u32,
|
||||
None,
|
||||
"0x1D86AD5eBb2380dAdEAF52f61f4F428C485460E9",
|
||||
),
|
||||
(
|
||||
"thumb soda tape crunch maple fresh imitate cancel order blind denial giraffe",
|
||||
3u32,
|
||||
None,
|
||||
"0xFB78b25f69A8e941036fEE2A5EeAf349D81D4ccc",
|
||||
),
|
||||
];
|
||||
TESTCASES
|
||||
.iter()
|
||||
.for_each(|(phrase, index, password, expected_addr)| {
|
||||
let wallet = match password {
|
||||
Some(psswd) => MnemonicBuilder::<English>::default()
|
||||
.phrase(*phrase)
|
||||
.index(*index)
|
||||
.unwrap()
|
||||
.password(psswd)
|
||||
.build()
|
||||
.unwrap(),
|
||||
None => MnemonicBuilder::<English>::default()
|
||||
.phrase(*phrase)
|
||||
.index(*index)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap(),
|
||||
};
|
||||
assert_eq!(&to_checksum(&wallet.address, None), expected_addr);
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mnemonic_write_read() {
|
||||
let dir = tempdir().unwrap();
|
||||
|
||||
// Construct a wallet from random mnemonic phrase and write it to the temp dir.
|
||||
let mut rng = rand::thread_rng();
|
||||
let wallet1 = MnemonicBuilder::<English>::default()
|
||||
.word_count(24)
|
||||
.derivation_path(TEST_DERIVATION_PATH)
|
||||
.unwrap()
|
||||
.write_to(dir.as_ref())
|
||||
.build_random(&mut rng)
|
||||
.unwrap();
|
||||
|
||||
// Ensure that only one file has been created.
|
||||
let paths = std::fs::read_dir(dir.as_ref()).unwrap();
|
||||
assert_eq!(paths.count(), 1);
|
||||
|
||||
// Use the newly created file's path to instantiate wallet.
|
||||
let phrase_path = dir.as_ref().join(to_checksum(&wallet1.address, None));
|
||||
let wallet2 = MnemonicBuilder::<English>::default()
|
||||
.phrase(phrase_path.to_str().unwrap())
|
||||
.derivation_path(TEST_DERIVATION_PATH)
|
||||
.unwrap()
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
// Ensure that both wallets belong to the same address.
|
||||
assert_eq!(wallet1.address, wallet2.address);
|
||||
|
||||
dir.close().unwrap();
|
||||
}
|
||||
}
|
|
@ -1,5 +1,12 @@
|
|||
mod hash;
|
||||
|
||||
mod mnemonic;
|
||||
pub use mnemonic::{MnemonicBuilder, MnemonicBuilderError};
|
||||
|
||||
mod private_key;
|
||||
pub use private_key::WalletError;
|
||||
|
||||
mod util;
|
||||
|
||||
#[cfg(feature = "yubihsm")]
|
||||
mod yubi;
|
||||
|
|
|
@ -1,14 +1,13 @@
|
|||
//! Specific helper functions for loading an offline K256 Private Key stored on disk
|
||||
use super::Wallet;
|
||||
|
||||
use crate::wallet::{mnemonic::MnemonicBuilderError, util::key_to_address};
|
||||
use coins_bip32::Bip32Error;
|
||||
use coins_bip39::MnemonicError;
|
||||
use eth_keystore::KeystoreError;
|
||||
use ethers_core::{
|
||||
k256::{
|
||||
ecdsa::SigningKey, elliptic_curve::error::Error as K256Error, EncodedPoint as K256PublicKey,
|
||||
},
|
||||
k256::ecdsa::{self, SigningKey},
|
||||
rand::{CryptoRng, Rng},
|
||||
types::Address,
|
||||
utils::keccak256,
|
||||
};
|
||||
use std::{path::Path, str::FromStr};
|
||||
use thiserror::Error;
|
||||
|
@ -16,9 +15,27 @@ use thiserror::Error;
|
|||
#[derive(Error, Debug)]
|
||||
/// Error thrown by the Wallet module
|
||||
pub enum WalletError {
|
||||
/// Error propagated from the BIP-32 crate
|
||||
#[error(transparent)]
|
||||
Bip32Error(#[from] Bip32Error),
|
||||
/// Error propagated from the BIP-39 crate
|
||||
#[error(transparent)]
|
||||
Bip39Error(#[from] MnemonicError),
|
||||
/// Underlying eth keystore error
|
||||
#[error(transparent)]
|
||||
EthKeystoreError(#[from] KeystoreError),
|
||||
/// Error propagated from k256's ECDSA module
|
||||
#[error(transparent)]
|
||||
EcdsaError(#[from] ecdsa::Error),
|
||||
/// Error propagated from the hex crate.
|
||||
#[error(transparent)]
|
||||
HexError(#[from] hex::FromHexError),
|
||||
/// Error propagated by IO operations
|
||||
#[error(transparent)]
|
||||
IoError(#[from] std::io::Error),
|
||||
/// Error propagated from the mnemonic builder module.
|
||||
#[error(transparent)]
|
||||
MnemonicBuilderError(#[from] MnemonicBuilderError),
|
||||
}
|
||||
|
||||
impl Clone for Wallet<SigningKey> {
|
||||
|
@ -33,8 +50,6 @@ impl Clone for Wallet<SigningKey> {
|
|||
}
|
||||
|
||||
impl Wallet<SigningKey> {
|
||||
// TODO: Add support for mnemonic
|
||||
|
||||
/// Creates a new random encrypted JSON with the provided password and stores it in the
|
||||
/// provided directory
|
||||
pub fn new_keystore<P, R, S>(dir: P, rng: &mut R, password: S) -> Result<Self, WalletError>
|
||||
|
@ -44,8 +59,7 @@ impl Wallet<SigningKey> {
|
|||
S: AsRef<[u8]>,
|
||||
{
|
||||
let (secret, _) = eth_keystore::new(dir, rng, password)?;
|
||||
let signer = SigningKey::from_bytes(secret.as_slice())
|
||||
.expect("private key should always be convertible to signing key");
|
||||
let signer = SigningKey::from_bytes(secret.as_slice())?;
|
||||
let address = key_to_address(&signer);
|
||||
Ok(Self {
|
||||
signer,
|
||||
|
@ -61,8 +75,7 @@ impl Wallet<SigningKey> {
|
|||
S: AsRef<[u8]>,
|
||||
{
|
||||
let secret = eth_keystore::decrypt_key(keypath, password)?;
|
||||
let signer = SigningKey::from_bytes(secret.as_slice())
|
||||
.expect("private key should always be convertible to signing key");
|
||||
let signer = SigningKey::from_bytes(secret.as_slice())?;
|
||||
let address = key_to_address(&signer);
|
||||
Ok(Self {
|
||||
signer,
|
||||
|
@ -83,15 +96,6 @@ impl Wallet<SigningKey> {
|
|||
}
|
||||
}
|
||||
|
||||
fn key_to_address(secret_key: &SigningKey) -> Address {
|
||||
// TODO: Can we do this in a better way?
|
||||
let uncompressed_pub_key = K256PublicKey::from(&secret_key.verify_key()).decompress();
|
||||
let public_key = uncompressed_pub_key.unwrap().to_bytes();
|
||||
debug_assert_eq!(public_key[0], 0x04);
|
||||
let hash = keccak256(&public_key[1..]);
|
||||
Address::from_slice(&hash[12..])
|
||||
}
|
||||
|
||||
impl PartialEq for Wallet<SigningKey> {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.signer.to_bytes().eq(&other.signer.to_bytes())
|
||||
|
@ -129,11 +133,11 @@ impl From<K256SecretKey> for Wallet<SigningKey> {
|
|||
}
|
||||
|
||||
impl FromStr for Wallet<SigningKey> {
|
||||
type Err = K256Error;
|
||||
type Err = WalletError;
|
||||
|
||||
fn from_str(src: &str) -> Result<Self, Self::Err> {
|
||||
let src = hex::decode(src).expect("invalid hex when reading PrivateKey");
|
||||
let sk = SigningKey::from_bytes(&src).unwrap(); // TODO
|
||||
let src = hex::decode(src)?;
|
||||
let sk = SigningKey::from_bytes(&src)?;
|
||||
Ok(sk.into())
|
||||
}
|
||||
}
|
||||
|
@ -142,6 +146,7 @@ impl FromStr for Wallet<SigningKey> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::Signer;
|
||||
use ethers_core::types::Address;
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
use ethers_core::{
|
||||
k256::{ecdsa::SigningKey, EncodedPoint as K256PublicKey},
|
||||
types::Address,
|
||||
utils::keccak256,
|
||||
};
|
||||
|
||||
pub fn key_to_address(secret_key: &SigningKey) -> Address {
|
||||
// TODO: Can we do this in a better way?
|
||||
let uncompressed_pub_key = K256PublicKey::from(&secret_key.verify_key()).decompress();
|
||||
let public_key = uncompressed_pub_key.unwrap().to_bytes();
|
||||
debug_assert_eq!(public_key[0], 0x04);
|
||||
let hash = keccak256(&public_key[1..]);
|
||||
Address::from_slice(&hash[12..])
|
||||
}
|
Loading…
Reference in New Issue