diff --git a/CHANGELOG.md b/CHANGELOG.md index b495f7e723..ac0d253e04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- wallet: added support for multiple accounts ([#1265]) - wallet: the wallet backend learned how to keep track of validator name, either hardcoded or by querying the status endpoint. - mixnet-contract: Replace all naked `-` with `saturating_sub`. - validator-api: add Swagger to document the REST API ([#1249]). @@ -20,6 +21,7 @@ [#1256]: https://github.com/nymtech/nym/pull/1256 [#1257]: https://github.com/nymtech/nym/pull/1257 [#1260]: https://github.com/nymtech/nym/pull/1260 +[#1265]: https://github.com/nymtech/nym/pull/1265 ## [nym-wallet-v1.0.4](https://github.com/nymtech/nym/tree/nym-wallet-v1.0.4) (2022-05-04) diff --git a/nym-wallet/package.json b/nym-wallet/package.json index 7d055fb77e..7cb5729725 100644 --- a/nym-wallet/package.json +++ b/nym-wallet/package.json @@ -42,7 +42,9 @@ "react-hook-form": "^7.14.2", "react-router-dom": "^5.2.0", "semver": "^6.3.0", + "string-to-color": "^2.2.2", "use-clipboard-copy": "^0.2.0", + "uuid": "^8.3.2", "yup": "^0.32.9" }, "devDependencies": { @@ -71,6 +73,7 @@ "@types/react-router": "^5.1.18", "@types/react-router-dom": "^5.1.8", "@types/semver": "^7.3.8", + "@types/uuid": "^8.3.4", "@typescript-eslint/eslint-plugin": "^5.13.0", "@typescript-eslint/parser": "^5.13.0", "babel-loader": "^8.2.2", diff --git a/nym-wallet/src-tauri/src/error.rs b/nym-wallet/src-tauri/src/error.rs index 077e949939..30c3ab5dcb 100644 --- a/nym-wallet/src-tauri/src/error.rs +++ b/nym-wallet/src-tauri/src/error.rs @@ -83,12 +83,22 @@ pub enum BackendError { WalletFileAlreadyExists, #[error("The wallet file is not found")] WalletFileNotFound, - #[error("Account ID not found in wallet")] - NoSuchIdInWallet, - #[error("Account ID already found in wallet")] - IdAlreadyExistsInWallet, + #[error("Login ID not found in wallet")] + WalletNoSuchLoginId, + #[error("Account ID not found in wallet login")] + WalletNoSuchAccountIdInWalletLogin, + #[error("Login ID already found in wallet")] + WalletLoginIdAlreadyExists, + #[error("Account ID already found in wallet login")] + WalletAccountIdAlreadyExistsInWalletLogin, #[error("Adding a different password to the wallet not currently supported")] WalletDifferentPasswordDetected, + #[error("Unexpted mnemonic account for login")] + WalletUnexpectedMnemonicAccount, + #[error("Unexpted multiple account entry for login")] + WalletUnexpectedMultipleAccounts, + #[error("Failed to derive address from mnemonic")] + FailedToDeriveAddress, } impl Serialize for BackendError { diff --git a/nym-wallet/src-tauri/src/main.rs b/nym-wallet/src-tauri/src/main.rs index 549fa72f78..1fd7a69817 100644 --- a/nym-wallet/src-tauri/src/main.rs +++ b/nym-wallet/src-tauri/src/main.rs @@ -35,14 +35,20 @@ fn main() { tauri::Builder::default() .manage(Arc::new(RwLock::new(State::default()))) .invoke_handler(tauri::generate_handler![ + mixnet::account::add_account_for_password, mixnet::account::connect_with_mnemonic, mixnet::account::create_new_account, mixnet::account::create_new_mnemonic, mixnet::account::create_password, mixnet::account::does_password_file_exist, mixnet::account::get_balance, + mixnet::account::list_accounts, + mixnet::account::sign_in_decrypted_account, mixnet::account::logout, + mixnet::account::remove_account_for_password, mixnet::account::remove_password, + mixnet::account::show_mnemonic_for_account_in_password, + mixnet::account::sign_in_decrypted_account, mixnet::account::sign_in_with_password, mixnet::account::switch_network, mixnet::account::validate_mnemonic, diff --git a/nym-wallet/src-tauri/src/operations/mixnet/account.rs b/nym-wallet/src-tauri/src/operations/mixnet/account.rs index d05e3a66b1..c2b4e1eff8 100644 --- a/nym-wallet/src-tauri/src/operations/mixnet/account.rs +++ b/nym-wallet/src-tauri/src/operations/mixnet/account.rs @@ -5,7 +5,7 @@ use crate::network::Network as WalletNetwork; use crate::network_config; use crate::nymd_client; use crate::state::State; -use crate::wallet_storage::{self, DEFAULT_WALLET_ACCOUNT_ID}; +use crate::wallet_storage::{self, DEFAULT_LOGIN_ID}; use bip39::{Language, Mnemonic}; use config::defaults::all::Network; @@ -21,6 +21,7 @@ use std::sync::Arc; use strum::IntoEnumIterator; use tokio::sync::RwLock; use url::Url; +use validator_client::nymd::wallet::{AccountData, DirectSecp256k1HdWallet}; use validator_client::{nymd::SigningNymdClient, Client}; @@ -51,6 +52,14 @@ pub struct CreatedAccount { mnemonic: String, } +#[cfg_attr(test, derive(ts_rs::TS))] +#[cfg_attr(test, ts(export, export_to = "../src/types/rust/createdaccount.ts"))] +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AccountEntry { + id: String, + address: String, +} + #[cfg_attr(test, derive(ts_rs::TS))] #[cfg_attr(test, ts(export, export_to = "../src/types/rust/balance.ts"))] #[derive(Serialize, Deserialize)] @@ -107,9 +116,8 @@ pub async fn create_new_account( } #[tauri::command] -pub fn create_new_mnemonic() -> Result { - let rand_mnemonic = random_mnemonic(); - Ok(rand_mnemonic.to_string()) +pub fn create_new_mnemonic() -> String { + random_mnemonic().to_string() } #[tauri::command] @@ -353,18 +361,18 @@ pub fn does_password_file_exist() -> Result { } #[tauri::command] -pub fn create_password(mnemonic: String, password: String) -> Result<(), BackendError> { +pub fn create_password(mnemonic: &str, password: String) -> Result<(), BackendError> { if does_password_file_exist()? { return Err(BackendError::WalletFileAlreadyExists); } log::info!("Creating password"); - let mnemonic = Mnemonic::from_str(&mnemonic)?; + let mnemonic = Mnemonic::from_str(mnemonic)?; let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); - // Currently we only support a single, default, id in the wallet - let id = wallet_storage::WalletAccountId::new(DEFAULT_WALLET_ACCOUNT_ID.to_string()); + // Currently we only support a single, default, login id in the wallet + let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string()); let password = wallet_storage::UserPassword::new(password); - wallet_storage::store_wallet_login_information(mnemonic, hd_path, id, &password) + wallet_storage::store_login_with_multiple_accounts(mnemonic, hd_path, login_id, &password) } #[tauri::command] @@ -375,15 +383,261 @@ pub async fn sign_in_with_password( log::info!("Signing in with password"); // Currently we only support a single, default, id in the wallet - let id = wallet_storage::WalletAccountId::new(DEFAULT_WALLET_ACCOUNT_ID.to_string()); + let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string()); let password = wallet_storage::UserPassword::new(password); - let stored_account = wallet_storage::load_existing_wallet_login_information(&id, &password)?; - _connect_with_mnemonic(stored_account.mnemonic().clone(), state).await + let stored_login = wallet_storage::load_existing_login(&login_id, &password)?; + + let mnemonic = extract_first_mnemonic(&stored_login)?; + let first_login_id_when_converting = login_id.into(); + set_state_with_all_accounts(stored_login, first_login_id_when_converting, state.clone()).await?; + + _connect_with_mnemonic(mnemonic, state).await +} + +fn extract_first_mnemonic( + stored_account: &wallet_storage::StoredLogin, +) -> Result { + let mnemonic = match stored_account { + wallet_storage::StoredLogin::Mnemonic(ref account) => account.mnemonic().clone(), + wallet_storage::StoredLogin::Multiple(ref accounts) => { + // Login using the first account in the list + accounts + .get_accounts() + .next() + .ok_or(BackendError::WalletNoSuchAccountIdInWalletLogin)? + .mnemonic() + .clone() + } + }; + + Ok(mnemonic) } #[tauri::command] pub fn remove_password() -> Result<(), BackendError> { log::info!("Removing password"); - let id = wallet_storage::WalletAccountId::new(DEFAULT_WALLET_ACCOUNT_ID.to_string()); - wallet_storage::remove_wallet_login_information(&id) + let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string()); + wallet_storage::remove_login(&login_id) +} + +#[tauri::command] +pub async fn add_account_for_password( + mnemonic: &str, + password: &str, + account_id: &str, + state: tauri::State<'_, Arc>>, +) -> Result { + log::info!("Adding account for the current password: {account_id}"); + let mnemonic = Mnemonic::from_str(mnemonic)?; + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + // Currently we only support a single, default, login id in the wallet + let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string()); + let account_id = wallet_storage::AccountId::new(account_id.to_string()); + let password = wallet_storage::UserPassword::new(password.to_string()); + + wallet_storage::append_account_to_login( + mnemonic.clone(), + hd_path, + login_id.clone(), + account_id.clone(), + &password, + )?; + + let address = { + let state = state.read().await; + let network: Network = state.current_network().into(); + derive_address(mnemonic, network.bech32_prefix())?.to_string() + }; + + // Re-read all the acccounts from the wallet to reset the state, rather than updating it + // incrementally + let stored_login = wallet_storage::load_existing_login(&login_id, &password)?; + // NOTE: since we are appending, this id shouldn't be needed, but setting the state is supposed + // to be a general function + let first_id_when_converting = login_id.into(); + set_state_with_all_accounts(stored_login, first_id_when_converting, state).await?; + + Ok(AccountEntry { + id: account_id.to_string(), + address, + }) +} + +// The first `AccoundId` when converting is the `LoginId` for the entry that was loaded. +async fn set_state_with_all_accounts( + stored_login: wallet_storage::StoredLogin, + first_id_when_converting: wallet_storage::AccountId, + state: tauri::State<'_, Arc>>, +) -> Result<(), BackendError> { + log::trace!("Set state with accounts:"); + let all_accounts: Vec<_> = stored_login + .unwrap_into_multiple_accounts(first_id_when_converting) + .into_accounts() + .collect(); + + for account in &all_accounts { + log::trace!("account: {:?}", account); + } + + let mut w_state = state.write().await; + w_state.set_all_accounts(all_accounts); + Ok(()) +} + +#[tauri::command] +pub async fn remove_account_for_password( + password: &str, + account_id: &str, + state: tauri::State<'_, Arc>>, +) -> Result<(), BackendError> { + log::info!("Removing account: {account_id}"); + // Currently we only support a single, default, id in the wallet + let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string()); + let account_id = wallet_storage::AccountId::new(account_id.to_string()); + let password = wallet_storage::UserPassword::new(password.to_string()); + wallet_storage::remove_account_from_login(&login_id, &account_id, &password)?; + + // Load to reset the internal state + let stored_login = wallet_storage::load_existing_login(&login_id, &password)?; + // NOTE: Since we removed from a multi-account login, this id shouldn't be needed, but setting + // the state is supposed to be a general function + let first_account_id_when_converting = login_id.into(); + set_state_with_all_accounts(stored_login, first_account_id_when_converting, state).await +} + +fn derive_address( + mnemonic: bip39::Mnemonic, + prefix: &str, +) -> Result { + DirectSecp256k1HdWallet::from_mnemonic(prefix, mnemonic)? + .try_derive_accounts()? + .first() + .map(AccountData::address) + .cloned() + .ok_or(BackendError::FailedToDeriveAddress) +} + +#[tauri::command] +pub async fn list_accounts( + state: tauri::State<'_, Arc>>, +) -> Result, BackendError> { + log::trace!("Listing accounts"); + let state = state.read().await; + let network: Network = state.current_network().into(); + let prefix = network.bech32_prefix(); + + let all_accounts = state + .get_all_accounts() + .map(|account| AccountEntry { + id: account.id().to_string(), + address: derive_address(account.mnemonic().clone(), prefix) + .unwrap() + .to_string(), + }) + .map(|account| { + log::trace!("{:?}", account); + account + }) + .collect(); + + Ok(all_accounts) +} + +#[tauri::command] +pub fn show_mnemonic_for_account_in_password( + account_id: String, + password: String, +) -> Result { + log::info!("Getting mnemonic for: {account_id}"); + let login_id = wallet_storage::LoginId::new(DEFAULT_LOGIN_ID.to_string()); + let account_id = wallet_storage::AccountId::new(account_id); + let password = wallet_storage::UserPassword::new(password); + let stored_account = wallet_storage::load_existing_login(&login_id, &password)?; + + let mnemonic = match stored_account { + wallet_storage::StoredLogin::Mnemonic(ref account) => account.mnemonic().clone(), + wallet_storage::StoredLogin::Multiple(ref accounts) => { + for account in accounts.get_accounts() { + log::debug!("{:?}", account); + } + accounts + .get_account(&account_id) + .ok_or(BackendError::WalletNoSuchAccountIdInWalletLogin)? + .mnemonic() + .clone() + } + }; + + Ok(mnemonic.to_string()) +} + +#[tauri::command] +pub async fn sign_in_decrypted_account( + account_id: &str, + state: tauri::State<'_, Arc>>, +) -> Result { + log::info!("Signing in to already decrypted account: {account_id}"); + let mnemonic = { + let state = state.read().await; + let account = &state + .get_all_accounts() + .find(|a| a.id().as_ref() == account_id) + .ok_or(BackendError::WalletNoSuchAccountIdInWalletLogin)?; + account.mnemonic().clone() + }; + + { + state.write().await.logout(); + } + + _connect_with_mnemonic(mnemonic, state).await +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::path::PathBuf; + + use crate::wallet_storage::{ + self, + account_data::{MnemonicAccount, WalletAccount}, + }; + + // This decryptes a stored wallet file using the same procedure as when signing in. Most tests + // related to the encryped wallet storage is in `wallet_storage`. + #[test] + fn decrypt_stored_wallet_for_sign_in() { + const SAVED_WALLET: &str = "src/wallet_storage/test-data/saved-wallet.json"; + let wallet_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(SAVED_WALLET); + let login_id = wallet_storage::LoginId::new("first".to_string()); + let account_id = wallet_storage::AccountId::new("first".to_string()); + let password = wallet_storage::UserPassword::new("password".to_string()); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + + let stored_login = + wallet_storage::load_existing_login_at_file(&wallet_file, &login_id, &password).unwrap(); + let mnemonic = extract_first_mnemonic(&stored_login).unwrap(); + + let expected_mnemonic = bip39::Mnemonic::from_str("country mean universe text phone begin deputy reject result good cram illness common cluster proud swamp digital patrol spread bar face december base kick").unwrap(); + assert_eq!(mnemonic, expected_mnemonic); + + let all_accounts: Vec<_> = stored_login + .unwrap_into_multiple_accounts(account_id.clone()) + .into_accounts() + .collect(); + + assert_eq!( + all_accounts, + vec![WalletAccount::new( + account_id, + MnemonicAccount::new(expected_mnemonic, hd_path), + )] + ); + } + + #[test] + fn decrypt_stored_wallet_multiple_for_sign_in() { + // WIP(JON): same as above but with file containing multiple accounts + } } diff --git a/nym-wallet/src-tauri/src/state.rs b/nym-wallet/src-tauri/src/state.rs index 13440fd62e..c5d06ef8f4 100644 --- a/nym-wallet/src-tauri/src/state.rs +++ b/nym-wallet/src-tauri/src/state.rs @@ -1,5 +1,6 @@ use crate::error::BackendError; use crate::network::Network; +use crate::wallet_storage::account_data::WalletAccount; use crate::{config, network_config}; use strum::IntoEnumIterator; @@ -46,6 +47,10 @@ pub struct State { signing_clients: HashMap>, current_network: Network, + // All the accounts the we get from decrypting the wallet. We hold on to these for being able to + // switch accounts on-the-fly + all_accounts: Vec, + /// Validators that have been fetched dynamically, probably during startup. fetched_validators: config::OptionalValidators, @@ -112,6 +117,14 @@ impl State { self.current_network } + pub(crate) fn set_all_accounts(&mut self, all_accounts: Vec) { + self.all_accounts = all_accounts + } + + pub(crate) fn get_all_accounts(&self) -> impl Iterator { + self.all_accounts.iter() + } + pub fn logout(&mut self) { self.signing_clients = HashMap::new(); } diff --git a/nym-wallet/src-tauri/src/wallet_storage/account_data.rs b/nym-wallet/src-tauri/src/wallet_storage/account_data.rs index 760208b6b8..26c7bce100 100644 --- a/nym-wallet/src-tauri/src/wallet_storage/account_data.rs +++ b/nym-wallet/src-tauri/src/wallet_storage/account_data.rs @@ -1,6 +1,19 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 +// The wallet storage is a single json file, containing multiple entries. These are referred to as +// Logins, and has a plaintext id tag attached. +// +// Each encrypted login contains either a single account, or a list of multiple accounts. +// +// NOTE: A not insignificant amount of complexity comes from being able to handle both these cases, +// instead of, for example, converting a single account to a list of multiple accounts with a single +// entry. This also avoids resaving the wallet file when opening a file created with an earlier +// version of the wallet. +// +// In the future we might want to simplify by dropping the support for a single account entry, +// instead treating as muliple accounts with one entry. + use cosmrs::bip32::DerivationPath; use serde::{Deserialize, Serialize}; use zeroize::Zeroize; @@ -8,15 +21,16 @@ use zeroize::Zeroize; use crate::error::BackendError; use super::encryption::EncryptedData; -use super::password::WalletAccountId; +use super::password::{AccountId, LoginId}; use super::UserPassword; const CURRENT_WALLET_FILE_VERSION: u32 = 1; +/// The wallet, stored as a serialized json file. #[derive(Serialize, Deserialize, Debug)] pub(crate) struct StoredWallet { version: u32, - accounts: Vec, + accounts: Vec, } impl StoredWallet { @@ -25,16 +39,52 @@ impl StoredWallet { self.version } - pub fn is_empty(&self) -> bool { - self.accounts.is_empty() - } - #[allow(unused)] pub fn len(&self) -> usize { self.accounts.len() } - pub fn remove_account(&mut self, id: &WalletAccountId) -> Option { + pub fn is_empty(&self) -> bool { + self.accounts.is_empty() + } + + pub fn add_encrypted_login(&mut self, new_login: EncryptedLogin) -> Result<(), BackendError> { + if self.get_encrypted_login(&new_login.id).is_ok() { + return Err(BackendError::WalletLoginIdAlreadyExists); + } + self.accounts.push(new_login); + Ok(()) + } + + fn get_encrypted_login(&self, id: &LoginId) -> Result<&EncryptedData, BackendError> { + self + .accounts + .iter() + .find(|account| &account.id == id) + .map(|account| &account.account) + .ok_or(BackendError::WalletNoSuchLoginId) + } + + fn get_encrypted_login_mut(&mut self, id: &LoginId) -> Result<&mut EncryptedLogin, BackendError> { + self + .accounts + .iter_mut() + .find(|account| &account.id == id) + .ok_or(BackendError::WalletNoSuchLoginId) + } + + #[cfg(test)] + pub fn get_encrypted_login_by_index(&self, index: usize) -> Option<&EncryptedLogin> { + self.accounts.get(index) + } + + pub fn replace_encrypted_login(&mut self, new_login: EncryptedLogin) -> Result<(), BackendError> { + let login = self.get_encrypted_login_mut(&new_login.id)?; + *login = new_login; + Ok(()) + } + + pub fn remove_encrypted_login(&mut self, id: &LoginId) -> Option { if let Some(index) = self.accounts.iter().position(|account| &account.id == id) { log::info!("Removing from wallet file: {id}"); Some(self.accounts.remove(index)) @@ -44,43 +94,15 @@ impl StoredWallet { } } - #[allow(unused)] - pub fn encrypted_account_by_index(&self, index: usize) -> Option<&EncryptedAccount> { - self.accounts.get(index) - } - - fn encrypted_account( + pub fn decrypt_login( &self, - id: &WalletAccountId, - ) -> Result<&EncryptedData, BackendError> { - self - .accounts - .iter() - .find(|account| &account.id == id) - .map(|account| &account.account) - .ok_or(BackendError::NoSuchIdInWallet) - } - - pub fn add_encrypted_account( - &mut self, - new_account: EncryptedAccount, - ) -> Result<(), BackendError> { - if self.encrypted_account(&new_account.id).is_ok() { - return Err(BackendError::IdAlreadyExistsInWallet); - } - self.accounts.push(new_account); - Ok(()) - } - - pub fn decrypt_account( - &self, - id: &WalletAccountId, + id: &LoginId, password: &UserPassword, - ) -> Result { - self.encrypted_account(id)?.decrypt_struct(password) + ) -> Result { + self.get_encrypted_login(id)?.decrypt_struct(password) } - pub fn decrypt_all(&self, password: &UserPassword) -> Result, BackendError> { + pub fn decrypt_all(&self, password: &UserPassword) -> Result, BackendError> { self .accounts .iter() @@ -102,39 +124,176 @@ impl Default for StoredWallet { } } +/// Each entry in the stored wallet file. An id field in plaintext and an encrypted stored login. #[derive(Serialize, Deserialize, Debug)] -pub(crate) struct EncryptedAccount { - pub id: WalletAccountId, - pub account: EncryptedData, +pub(crate) struct EncryptedLogin { + pub id: LoginId, + pub account: EncryptedData, } -// future-proofing +impl EncryptedLogin { + pub(crate) fn encrypt( + id: LoginId, + login: &StoredLogin, + password: &UserPassword, + ) -> Result { + Ok(EncryptedLogin { + id, + account: super::encryption::encrypt_struct(login, password)?, + }) + } +} + +/// A stored login is either a account, such as a mnemonic, or a list of multiple accounts where +/// each has an inner id. Future proofed for having private key backed accounts. #[derive(Serialize, Deserialize, Debug, Zeroize)] #[serde(untagged)] #[zeroize(drop)] -pub(crate) enum StoredAccount { +pub(crate) enum StoredLogin { Mnemonic(MnemonicAccount), // PrivateKey(PrivateKeyAccount) + Multiple(MultipleAccounts), } -impl StoredAccount { - pub(crate) fn new_mnemonic_backed_account( - mnemonic: bip39::Mnemonic, - hd_path: DerivationPath, - ) -> StoredAccount { - StoredAccount::Mnemonic(MnemonicAccount { mnemonic, hd_path }) +impl StoredLogin { + #[cfg(test)] + pub(crate) fn as_mnemonic_account(&self) -> Option<&MnemonicAccount> { + match self { + StoredLogin::Mnemonic(mn) => Some(mn), + StoredLogin::Multiple(_) => None, + } } - // If we add accounts backed by something that is not a mnemonic, this should probably be changed - // to return `Option<..>`. - pub(crate) fn mnemonic(&self) -> &bip39::Mnemonic { + #[cfg(test)] + pub(crate) fn as_multiple_accounts(&self) -> Option<&MultipleAccounts> { match self { - StoredAccount::Mnemonic(account) => account.mnemonic(), + StoredLogin::Mnemonic(_) => None, + StoredLogin::Multiple(accounts) => Some(accounts), + } + } + + // Return the login as multiple accounts, and if there is only a single mnemonic backed account, + // return a set containing only the single account paired with the account id passed as function + // argument. + pub(crate) fn unwrap_into_multiple_accounts(self, id: AccountId) -> MultipleAccounts { + match self { + StoredLogin::Mnemonic(ref account) => vec![WalletAccount::new(id, account.clone())].into(), + StoredLogin::Multiple(ref accounts) => accounts.clone(), } } } -#[derive(Serialize, Deserialize, Debug)] +/// Multiple stored accounts, each entry having an id and a data field. +#[derive(Serialize, Deserialize, Clone, Debug, Zeroize, PartialEq, Eq)] +pub(crate) struct MultipleAccounts { + accounts: Vec, +} + +impl MultipleAccounts { + pub(crate) fn new() -> Self { + MultipleAccounts { + accounts: Vec::new(), + } + } + + pub(crate) fn get_accounts(&self) -> impl Iterator { + self.accounts.iter() + } + + pub(crate) fn get_account(&self, id: &AccountId) -> Option<&WalletAccount> { + self.accounts.iter().find(|account| &account.id == id) + } + + pub(crate) fn into_accounts(self) -> impl Iterator { + self.accounts.into_iter() + } + + #[allow(unused)] + pub(crate) fn len(&self) -> usize { + self.accounts.len() + } + + pub(crate) fn is_empty(&self) -> bool { + self.accounts.is_empty() + } + + pub(crate) fn add( + &mut self, + id: AccountId, + mnemonic: bip39::Mnemonic, + hd_path: DerivationPath, + ) -> Result<(), BackendError> { + if self.get_account(&id).is_some() { + Err(BackendError::WalletAccountIdAlreadyExistsInWalletLogin) + } else { + self.accounts.push(WalletAccount::new( + id, + MnemonicAccount::new(mnemonic, hd_path), + )); + Ok(()) + } + } + + pub(crate) fn remove(&mut self, id: &AccountId) -> Result<(), BackendError> { + if self.get_account(id).is_none() { + return Err(BackendError::WalletNoSuchAccountIdInWalletLogin); + } + self.accounts.retain(|accounts| &accounts.id != id); + Ok(()) + } +} + +impl From> for MultipleAccounts { + fn from(accounts: Vec) -> MultipleAccounts { + Self { accounts } + } +} + +/// An entry in the list of stored accounts +#[derive(Serialize, Deserialize, Clone, Debug, Zeroize, PartialEq, Eq)] +pub(crate) struct WalletAccount { + id: AccountId, + account: AccountData, +} + +impl WalletAccount { + pub(crate) fn new(id: AccountId, mnemonic_account: MnemonicAccount) -> Self { + Self { + id, + account: AccountData::Mnemonic(mnemonic_account), + } + } + + pub(crate) fn id(&self) -> &AccountId { + &self.id + } + + pub(crate) fn mnemonic(&self) -> &bip39::Mnemonic { + match self.account { + AccountData::Mnemonic(ref account) => account.mnemonic(), + } + } + + #[cfg(test)] + pub(crate) fn hd_path(&self) -> &DerivationPath { + match self.account { + AccountData::Mnemonic(ref account) => account.hd_path(), + } + } +} + +/// An account usually is a mnemonic account, but in the future it might be backed by a private +/// key. +#[derive(Serialize, Deserialize, Clone, Debug, Zeroize, PartialEq, Eq)] +#[serde(untagged)] +#[zeroize(drop)] +enum AccountData { + Mnemonic(MnemonicAccount), + // PrivateKey(PrivateKeyAccount) +} + +/// An account backed by a unique mnemonic. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] pub(crate) struct MnemonicAccount { mnemonic: bip39::Mnemonic, #[serde(with = "display_hd_path")] @@ -142,11 +301,15 @@ pub(crate) struct MnemonicAccount { } impl MnemonicAccount { + pub(crate) fn new(mnemonic: bip39::Mnemonic, hd_path: DerivationPath) -> Self { + Self { mnemonic, hd_path } + } + pub(crate) fn mnemonic(&self) -> &bip39::Mnemonic { &self.mnemonic } - #[allow(unused)] + #[cfg(test)] pub(crate) fn hd_path(&self) -> &DerivationPath { &self.hd_path } diff --git a/nym-wallet/src-tauri/src/wallet_storage/mod.rs b/nym-wallet/src-tauri/src/wallet_storage/mod.rs index 862e23e682..43edb72987 100644 --- a/nym-wallet/src-tauri/src/wallet_storage/mod.rs +++ b/nym-wallet/src-tauri/src/wallet_storage/mod.rs @@ -1,24 +1,38 @@ // Copyright 2022 - Nym Technologies SA // SPDX-License-Identifier: Apache-2.0 -pub(crate) use crate::wallet_storage::password::{UserPassword, WalletAccountId}; +/// The wallet storage file contains a set of logins, each with an associated login ID and an +/// encrypted field. Once decrypted, each login contains either an account, or a set of accounts. +/// One difference is that the latter have an associated account ID. +/// +/// Wallet +/// - Login +/// -- Account +/// --- Mnemonic +pub(crate) use crate::wallet_storage::account_data::StoredLogin; +pub(crate) use crate::wallet_storage::password::{AccountId, LoginId, UserPassword}; use crate::error::BackendError; use crate::platform_constants::{STORAGE_DIR_NAME, WALLET_INFO_FILENAME}; -use crate::wallet_storage::account_data::StoredAccount; -use crate::wallet_storage::encryption::encrypt_struct; use cosmrs::bip32::DerivationPath; use std::fs::{self, create_dir_all, OpenOptions}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use self::account_data::{EncryptedAccount, StoredWallet}; +#[cfg(test)] +use self::account_data::MnemonicAccount; +use self::account_data::{EncryptedLogin, MultipleAccounts, StoredWallet}; pub(crate) mod account_data; pub(crate) mod encryption; mod password; -pub(crate) const DEFAULT_WALLET_ACCOUNT_ID: &str = "default"; +/// The default wallet (top-level) login id. +pub(crate) const DEFAULT_LOGIN_ID: &str = "default"; + +/// When converting a single account entry to one that contains many, the first account will use +/// this name. +pub(crate) const DEFAULT_FIRST_ACCOUNT_NAME: &str = "Account 1"; fn get_storage_directory() -> Result { tauri::api::path::local_data_dir() @@ -30,14 +44,25 @@ pub(crate) fn wallet_login_filepath() -> Result { get_storage_directory().map(|dir| dir.join(WALLET_INFO_FILENAME)) } -#[allow(unused)] -pub(crate) fn load_existing_wallet(password: &UserPassword) -> Result { - let store_dir = get_storage_directory()?; - let filepath = store_dir.join(WALLET_INFO_FILENAME); - load_existing_wallet_at_file(filepath) +fn write_to_file(filepath: &Path, wallet: &StoredWallet) -> Result<(), BackendError> { + let file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(filepath)?; + + Ok(serde_json::to_writer_pretty(file, &wallet)?) } -fn load_existing_wallet_at_file(filepath: PathBuf) -> Result { +/// Load stored wallet file +#[allow(unused)] +pub(crate) fn load_existing_wallet() -> Result { + let store_dir = get_storage_directory()?; + let filepath = store_dir.join(WALLET_INFO_FILENAME); + load_existing_wallet_at_file(&filepath) +} + +fn load_existing_wallet_at_file(filepath: &Path) -> Result { if !filepath.exists() { return Err(BackendError::WalletFileNotFound); } @@ -46,27 +71,32 @@ fn load_existing_wallet_at_file(filepath: PathBuf) -> Result Result { +) -> Result { let store_dir = get_storage_directory()?; let filepath = store_dir.join(WALLET_INFO_FILENAME); - load_existing_wallet_login_information_at_file(filepath, id, password) + load_existing_login_at_file(&filepath, id, password) } -fn load_existing_wallet_login_information_at_file( - filepath: PathBuf, - id: &WalletAccountId, +pub(crate) fn load_existing_login_at_file( + filepath: &Path, + id: &LoginId, password: &UserPassword, -) -> Result { - load_existing_wallet_at_file(filepath)?.decrypt_account(id, password) +) -> Result { + load_existing_wallet_at_file(filepath)?.decrypt_login(id, password) } -pub(crate) fn store_wallet_login_information( +// DEPRECATED: only used in tests, where it's used to test supporting older wallet formats +#[allow(unused)] +#[cfg(test)] +pub(crate) fn store_login( mnemonic: bip39::Mnemonic, hd_path: DerivationPath, - id: WalletAccountId, + id: LoginId, password: &UserPassword, ) -> Result<(), BackendError> { // make sure the entire directory structure exists @@ -74,273 +104,1325 @@ pub(crate) fn store_wallet_login_information( create_dir_all(&store_dir)?; let filepath = store_dir.join(WALLET_INFO_FILENAME); - store_wallet_login_information_at_file(filepath, mnemonic, hd_path, id, password) + store_login_at_file(&filepath, mnemonic, hd_path, id, password) } -fn store_wallet_login_information_at_file( - filepath: PathBuf, +// DEPRECATED: only used in tests, where it's used to test supporting older wallet formats +#[cfg(test)] +fn store_login_at_file( + filepath: &Path, mnemonic: bip39::Mnemonic, hd_path: DerivationPath, - id: WalletAccountId, + id: LoginId, password: &UserPassword, ) -> Result<(), BackendError> { - let mut stored_wallet = match load_existing_wallet_at_file(filepath.clone()) { + let mut stored_wallet = match load_existing_wallet_at_file(filepath) { Err(BackendError::WalletFileNotFound) => StoredWallet::default(), result => result?, }; - // Confirm that the given password also can unlock the other entries + // Confirm that the given password also can unlock the other entries. + // This is restriction we can relax in the future, but for now it's a sanity check. if !stored_wallet.password_can_decrypt_all(password) { return Err(BackendError::WalletDifferentPasswordDetected); } - let new_account = StoredAccount::new_mnemonic_backed_account(mnemonic, hd_path); - let new_encrypted_account = EncryptedAccount { - id, - account: encrypt_struct(&new_account, password)?, - }; + let new_account = MnemonicAccount::new(mnemonic, hd_path); + let new_login = StoredLogin::Mnemonic(new_account); + let new_encrypted_account = EncryptedLogin::encrypt(id, &new_login, password)?; + stored_wallet.add_encrypted_login(new_encrypted_account)?; - stored_wallet.add_encrypted_account(new_encrypted_account)?; - - let file = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(filepath)?; - - Ok(serde_json::to_writer_pretty(file, &stored_wallet)?) + write_to_file(filepath, &stored_wallet) } -pub(crate) fn remove_wallet_login_information(id: &WalletAccountId) -> Result<(), BackendError> { - let store_dir = get_storage_directory()?; - let filepath = store_dir.join(WALLET_INFO_FILENAME); - remove_wallet_login_information_at_file(filepath, id) -} - -pub(crate) fn remove_wallet_login_information_at_file( - filepath: PathBuf, - id: &WalletAccountId, +pub(crate) fn store_login_with_multiple_accounts( + mnemonic: bip39::Mnemonic, + hd_path: DerivationPath, + id: LoginId, + password: &UserPassword, ) -> Result<(), BackendError> { - let mut stored_wallet = match load_existing_wallet_at_file(filepath.clone()) { + // make sure the entire directory structure exists + let store_dir = get_storage_directory()?; + create_dir_all(&store_dir)?; + let filepath = store_dir.join(WALLET_INFO_FILENAME); + + store_login_with_multiple_accounts_at_file(&filepath, mnemonic, hd_path, id, password) +} + +fn store_login_with_multiple_accounts_at_file( + filepath: &Path, + mnemonic: bip39::Mnemonic, + hd_path: DerivationPath, + id: LoginId, + password: &UserPassword, +) -> Result<(), BackendError> { + let mut stored_wallet = match load_existing_wallet_at_file(filepath) { Err(BackendError::WalletFileNotFound) => StoredWallet::default(), result => result?, }; + // Confirm that the given password also can unlock the other entries. + // This is restriction we can relax in the future, but for now it's a sanity check. + if !stored_wallet.password_can_decrypt_all(password) { + return Err(BackendError::WalletDifferentPasswordDetected); + } + + let mut new_accounts = MultipleAccounts::new(); + new_accounts.add(DEFAULT_FIRST_ACCOUNT_NAME.into(), mnemonic, hd_path)?; + let new_login = StoredLogin::Multiple(new_accounts); + let new_encrypted_login = EncryptedLogin::encrypt(id, &new_login, password)?; + + stored_wallet.add_encrypted_login(new_encrypted_login)?; + + write_to_file(filepath, &stored_wallet) +} + +/// Append an account to an already existing top-level encrypted account entry. +/// If the existing top-level entry is just a single account, it will be converted to the first +/// account in the list of accounts associated with the encrypted entry. The inner id for this +/// entry will be set to the same as the outer, unencrypted, id. +pub(crate) fn append_account_to_login( + mnemonic: bip39::Mnemonic, + hd_path: DerivationPath, + id: LoginId, + inner_id: AccountId, + password: &UserPassword, +) -> Result<(), BackendError> { + // make sure the entire directory structure exists + let store_dir = get_storage_directory()?; + create_dir_all(&store_dir)?; + let filepath = store_dir.join(WALLET_INFO_FILENAME); + + append_account_to_login_at_file(&filepath, mnemonic, hd_path, id, inner_id, password) +} + +fn append_account_to_login_at_file( + filepath: &Path, + mnemonic: bip39::Mnemonic, + hd_path: DerivationPath, + id: LoginId, + inner_id: AccountId, + password: &UserPassword, +) -> Result<(), BackendError> { + let mut stored_wallet = load_existing_wallet_at_file(filepath)?; + + let decrypted_login = stored_wallet.decrypt_login(&id, password)?; + + // Add accounts to the inner structure. + // Note that in case we only have single account entry, without an inner_id, we convert to + // multiple accounts and we set the first inner_id to id. + let first_id_when_converting = id.clone().into(); + let mut accounts = decrypted_login.unwrap_into_multiple_accounts(first_id_when_converting); + accounts.add(inner_id, mnemonic, hd_path)?; + + let encrypted_accounts = EncryptedLogin::encrypt(id, &StoredLogin::Multiple(accounts), password)?; + + stored_wallet.replace_encrypted_login(encrypted_accounts)?; + + write_to_file(filepath, &stored_wallet) +} + +/// Remove the entire encrypted login entry for the given `id`. This means potentially removing all +/// associated accounts! +/// If this was the last entry in the file, the file is removed. +pub(crate) fn remove_login(id: &LoginId) -> Result<(), BackendError> { + let store_dir = get_storage_directory()?; + let filepath = store_dir.join(WALLET_INFO_FILENAME); + remove_login_at_file(&filepath, id) +} + +fn remove_login_at_file(filepath: &Path, id: &LoginId) -> Result<(), BackendError> { + log::warn!("Removing wallet account with id: {id}. This includes all associated accounts!"); + let mut stored_wallet = load_existing_wallet_at_file(filepath)?; + if stored_wallet.is_empty() { log::info!("Removing file: {:#?}", filepath); return Ok(fs::remove_file(filepath)?); } stored_wallet - .remove_account(id) - .ok_or(BackendError::NoSuchIdInWallet)?; + .remove_encrypted_login(id) + .ok_or(BackendError::WalletNoSuchLoginId)?; if stored_wallet.is_empty() { log::info!("Removing file: {:#?}", filepath); Ok(fs::remove_file(filepath)?) } else { - let file = OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(filepath)?; + write_to_file(filepath, &stored_wallet) + } +} - Ok(serde_json::to_writer_pretty(file, &stored_wallet)?) +/// Remove an account from inside the encrypted login. +/// - If the encrypted login is just a single account, abort to be on the safe side. +/// - If it is the last associated account with that login, the encrypted login will be removed. +/// - If this was the last encrypted login in the file, it will be removed. +pub(crate) fn remove_account_from_login( + id: &LoginId, + inner_id: &AccountId, + password: &UserPassword, +) -> Result<(), BackendError> { + let store_dir = get_storage_directory()?; + let filepath = store_dir.join(WALLET_INFO_FILENAME); + remove_account_from_login_at_file(&filepath, id, inner_id, password) +} + +fn remove_account_from_login_at_file( + filepath: &Path, + id: &LoginId, + inner_id: &AccountId, + password: &UserPassword, +) -> Result<(), BackendError> { + log::info!("Removing associated account from login account: {id}"); + let mut stored_wallet = load_existing_wallet_at_file(filepath)?; + + let mut decrypted_login = stored_wallet.decrypt_login(id, password)?; + + // Remove the account + let is_empty = match decrypted_login { + StoredLogin::Mnemonic(_) => { + log::warn!("Encountered mnemonic login instead of list of accounts, aborting"); + return Err(BackendError::WalletUnexpectedMnemonicAccount); + } + StoredLogin::Multiple(ref mut accounts) => { + accounts.remove(inner_id)?; + accounts.is_empty() + } + }; + + // Remove the login, or encrypt the new updated login + if is_empty { + stored_wallet + .remove_encrypted_login(id) + .ok_or(BackendError::WalletNoSuchLoginId)?; + } else { + let encrypted_accounts = EncryptedLogin::encrypt(id.clone(), &decrypted_login, password)?; + stored_wallet.replace_encrypted_login(encrypted_accounts)?; + } + + // Remove the file, or write the new file + if stored_wallet.is_empty() { + log::info!("Removing file: {:#?}", filepath); + Ok(fs::remove_file(filepath)?) + } else { + write_to_file(filepath, &stored_wallet) } } #[cfg(test)] mod tests { + use crate::wallet_storage::account_data::{MnemonicAccount, WalletAccount}; + use super::*; use config::defaults::COSMOS_DERIVATION_PATH; use std::str::FromStr; use tempfile::tempdir; - // I'm not 100% sure how to feel about having to touch the file system at all #[test] - #[allow(clippy::too_many_lines)] - fn storing_wallet_information() { + fn trying_to_load_nonexistant_wallet_fails() { let store_dir = tempdir().unwrap(); let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); - - let dummy_account1 = bip39::Mnemonic::generate(24).unwrap(); - let dummy_account2 = bip39::Mnemonic::generate(24).unwrap(); - let cosmos_hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); - let different_hd_path: DerivationPath = "m".parse().unwrap(); - + let id1 = LoginId::new("first".to_string()); let password = UserPassword::new("password".to_string()); - let bad_password = UserPassword::new("bad-password".to_string()); - let id1 = WalletAccountId::new("first".to_string()); - let id2 = WalletAccountId::new("second".to_string()); - - // Nothing was stored on the disk assert!(matches!( - load_existing_wallet_at_file(wallet_file.clone()), + load_existing_wallet_at_file(&wallet_file), Err(BackendError::WalletFileNotFound), )); assert!(matches!( - load_existing_wallet_login_information_at_file(wallet_file.clone(), &id1, &password), + load_existing_login_at_file(&wallet_file, &id1, &password), Err(BackendError::WalletFileNotFound), )); + remove_login_at_file(&wallet_file, &id1).unwrap_err(); + } - // Store the first account - store_wallet_login_information_at_file( - wallet_file.clone(), - dummy_account1.clone(), - cosmos_hd_path.clone(), + #[test] + fn store_single_login() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + + store_login_at_file(&wallet_file, account1, hd_path, id1.clone(), &password).unwrap(); + + let stored_wallet = load_existing_wallet_at_file(&wallet_file).unwrap(); + assert_eq!(stored_wallet.len(), 1); + + let login = stored_wallet.get_encrypted_login_by_index(0).unwrap(); + assert_eq!(login.id, id1); + + // some actual ciphertext was saved + assert!(!login.account.ciphertext().is_empty()); + } + + #[test] + fn store_single_login_with_multi() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let cosmos_hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + + store_login_with_multiple_accounts_at_file( + &wallet_file, + account1, + cosmos_hd_path, id1.clone(), &password, ) .unwrap(); - let stored_wallet = load_existing_wallet_at_file(wallet_file.clone()).unwrap(); + let stored_wallet = load_existing_wallet_at_file(&wallet_file).unwrap(); assert_eq!(stored_wallet.len(), 1); - assert_eq!( - stored_wallet.encrypted_account_by_index(0).unwrap().id, - WalletAccountId::new("first".to_string()) - ); - let encrypted_blob = &stored_wallet.encrypted_account_by_index(0).unwrap().account; + + let login = stored_wallet.get_encrypted_login_by_index(0).unwrap(); + assert_eq!(login.id, id1); // some actual ciphertext was saved - assert!(!encrypted_blob.ciphertext().is_empty()); + assert!(!login.account.ciphertext().is_empty()); + } + + #[test] + fn store_twice_for_the_same_id_fails() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + + // Store the first login + store_login_at_file( + &wallet_file, + account1.clone(), + hd_path.clone(), + id1.clone(), + &password, + ) + .unwrap(); + + // and storing the same id again fails + assert!(matches!( + store_login_at_file(&wallet_file, account1, hd_path, id1, &password,), + Err(BackendError::WalletLoginIdAlreadyExists), + )); + } + + #[test] + fn store_twice_for_the_same_id_fails_with_multiple() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + + // Store the first login + store_login_with_multiple_accounts_at_file( + &wallet_file, + account1.clone(), + hd_path.clone(), + id1.clone(), + &password, + ) + .unwrap(); + + // and storing the same id again fails + assert!(matches!( + store_login_with_multiple_accounts_at_file(&wallet_file, account1, hd_path, id1, &password,), + Err(BackendError::WalletLoginIdAlreadyExists), + )); + } + + #[test] + fn load_with_wrong_password_fails() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let bad_password = UserPassword::new("bad-password".to_string()); + let id1 = LoginId::new("first".to_string()); + + store_login_at_file(&wallet_file, account1, hd_path, id1.clone(), &password).unwrap(); + + // Trying to load it with wrong password now fails + assert!(matches!( + load_existing_login_at_file(&wallet_file, &id1, &bad_password), + Err(BackendError::DecryptionError), + )); + } + + #[test] + fn load_with_wrong_password_fails_with_multi() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let bad_password = UserPassword::new("bad-password".to_string()); + let id1 = LoginId::new("first".to_string()); + + store_login_with_multiple_accounts_at_file( + &wallet_file, + account1, + hd_path, + id1.clone(), + &password, + ) + .unwrap(); + + // Trying to load it with wrong password now fails + assert!(matches!( + load_existing_login_at_file(&wallet_file, &id1, &bad_password), + Err(BackendError::DecryptionError), + )); + } + + #[test] + fn load_with_wrong_id_fails() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = LoginId::new("second".to_string()); + + store_login_at_file(&wallet_file, account1, hd_path, id1, &password).unwrap(); + + // Trying to load with the wrong id + assert!(matches!( + load_existing_login_at_file(&wallet_file, &id2, &password), + Err(BackendError::WalletNoSuchLoginId), + )); + } + + #[test] + fn load_with_wrong_id_fails_with_multi() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = LoginId::new("second".to_string()); + + store_login_with_multiple_accounts_at_file(&wallet_file, account1, hd_path, id1, &password) + .unwrap(); + + // Trying to load with the wrong id + assert!(matches!( + load_existing_login_at_file(&wallet_file, &id2, &password), + Err(BackendError::WalletNoSuchLoginId), + )); + } + + #[test] + fn store_and_load_a_single_login() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + + store_login_at_file( + &wallet_file, + account1.clone(), + hd_path.clone(), + id1.clone(), + &password, + ) + .unwrap(); + + let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); + let acc = loaded_login.as_mnemonic_account().unwrap(); + assert_eq!(&account1, acc.mnemonic()); + assert_eq!(&hd_path, acc.hd_path()); + } + + #[test] + fn store_and_load_a_single_login_with_multi() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let acc1 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + + store_login_with_multiple_accounts_at_file( + &wallet_file, + acc1.clone(), + hd_path.clone(), + id1.clone(), + &password, + ) + .unwrap(); + + let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); + let accounts = loaded_login.as_multiple_accounts().unwrap(); + assert_eq!(accounts.len(), 1); + let account = accounts + .get_account(&DEFAULT_FIRST_ACCOUNT_NAME.into()) + .unwrap(); + assert_eq!(account.id().as_ref(), DEFAULT_FIRST_ACCOUNT_NAME); + assert_eq!(account.mnemonic(), &acc1); + assert_eq!(account.hd_path(), &hd_path); + } + + #[test] + fn store_a_second_login_with_a_different_password_fails() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account2 = bip39::Mnemonic::generate(24).unwrap(); + let cosmos_hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let bad_password = UserPassword::new("bad-password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = LoginId::new("second".to_string()); + + store_login_at_file( + &wallet_file, + account1, + cosmos_hd_path.clone(), + id1, + &password, + ) + .unwrap(); + + // Can't store a second login if you use different password + assert!(matches!( + store_login_at_file(&wallet_file, account2, cosmos_hd_path, id2, &bad_password), + Err(BackendError::WalletDifferentPasswordDetected), + )); + } + + #[test] + fn store_a_second_login_with_a_different_password_fails_with_multi() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account2 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let bad_password = UserPassword::new("bad-password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = LoginId::new("second".to_string()); + + store_login_with_multiple_accounts_at_file( + &wallet_file, + account1, + hd_path.clone(), + id1, + &password, + ) + .unwrap(); + + // Can't store a second login if you use different password + assert!(matches!( + store_login_with_multiple_accounts_at_file( + &wallet_file, + account2, + hd_path, + id2, + &bad_password + ), + Err(BackendError::WalletDifferentPasswordDetected), + )); + } + + #[test] + fn store_two_mnemonic_accounts_gives_different_salts_and_iv() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account2 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let different_hd_path: DerivationPath = "m".parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = LoginId::new("second".to_string()); + + // Store the first account + store_login_at_file(&wallet_file, account1, hd_path, id1, &password).unwrap(); + + let stored_wallet = load_existing_wallet_at_file(&wallet_file).unwrap(); + let encrypted_blob = &stored_wallet + .get_encrypted_login_by_index(0) + .unwrap() + .account; // keep track of salt and iv for future assertion let original_iv = encrypted_blob.iv().to_vec(); let original_salt = encrypted_blob.salt().to_vec(); - // trying to load it with wrong password now fails - assert!(matches!( - load_existing_wallet_login_information_at_file(wallet_file.clone(), &id1, &bad_password), - Err(BackendError::DecryptionError), - )); - // and with the wrong id also fails - assert!(matches!( - load_existing_wallet_login_information_at_file(wallet_file.clone(), &id2, &password), - Err(BackendError::NoSuchIdInWallet), - )); + // Add an extra account + store_login_at_file(&wallet_file, account2, different_hd_path, id2, &password).unwrap(); - // and storing the same id again fails - assert!(matches!( - store_wallet_login_information_at_file( - wallet_file.clone(), - dummy_account1.clone(), - cosmos_hd_path.clone(), - id1.clone(), - &password, - ), - Err(BackendError::IdAlreadyExistsInWallet), - )); - - let loaded_account = - load_existing_wallet_login_information_at_file(wallet_file.clone(), &id1, &password).unwrap(); - - let StoredAccount::Mnemonic(ref acc) = loaded_account; - assert_eq!(&dummy_account1, acc.mnemonic()); - assert_eq!(&cosmos_hd_path, acc.hd_path()); - - // Can't store extra account if you use different password - assert!(matches!( - store_wallet_login_information_at_file( - wallet_file.clone(), - dummy_account2.clone(), - cosmos_hd_path.clone(), - id2.clone(), - &bad_password - ), - Err(BackendError::WalletDifferentPasswordDetected), - )); - - // add extra account properly now - store_wallet_login_information_at_file( - wallet_file.clone(), - dummy_account2.clone(), - different_hd_path.clone(), - id2.clone(), - &password, - ) - .unwrap(); - - let loaded_accounts = load_existing_wallet_at_file(wallet_file.clone()).unwrap(); - assert_eq!(2, loaded_accounts.len()); + let loaded_accounts = load_existing_wallet_at_file(&wallet_file).unwrap(); + assert_eq!(loaded_accounts.len(), 2); let encrypted_blob = &loaded_accounts - .encrypted_account_by_index(1) + .get_encrypted_login_by_index(1) .unwrap() .account; // fresh IV and salt are used assert_ne!(original_iv, encrypted_blob.iv()); assert_ne!(original_salt, encrypted_blob.salt()); + } + + #[test] + fn store_two_mnemonic_accounts_using_two_logins() { + let store_dir = tempdir().unwrap(); + let wallet = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account2 = bip39::Mnemonic::generate(24).unwrap(); + let cosmos_hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let different_hd_path: DerivationPath = "m".parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = LoginId::new("second".to_string()); + + // Store the first account + store_login_at_file( + &wallet, + account1.clone(), + cosmos_hd_path.clone(), + id1.clone(), + &password, + ) + .unwrap(); + + let login = load_existing_login_at_file(&wallet, &id1, &password).unwrap(); + let acc = login.as_mnemonic_account().unwrap(); + assert_eq!(&account1, acc.mnemonic()); + assert_eq!(&cosmos_hd_path, acc.hd_path()); + + // Add an extra account + store_login_at_file( + &wallet, + account2.clone(), + different_hd_path.clone(), + id2.clone(), + &password, + ) + .unwrap(); // first account should be unchanged - let loaded_account = - load_existing_wallet_login_information_at_file(wallet_file.clone(), &id1, &password).unwrap(); - let StoredAccount::Mnemonic(ref acc1) = loaded_account; - assert_eq!(&dummy_account1, acc1.mnemonic()); + let loaded_login = load_existing_login_at_file(&wallet, &id1, &password).unwrap(); + let acc1 = loaded_login.as_mnemonic_account().unwrap(); + assert_eq!(&account1, acc1.mnemonic()); assert_eq!(&cosmos_hd_path, acc1.hd_path()); - let loaded_account = - load_existing_wallet_login_information_at_file(wallet_file.clone(), &id2, &password).unwrap(); - let StoredAccount::Mnemonic(ref acc2) = loaded_account; - assert_eq!(&dummy_account2, acc2.mnemonic()); + let loaded_login = load_existing_login_at_file(&wallet, &id2, &password).unwrap(); + let acc2 = loaded_login.as_mnemonic_account().unwrap(); + assert_eq!(&account2, acc2.mnemonic()); assert_eq!(&different_hd_path, acc2.hd_path()); + } + + #[test] + fn store_one_mnemonic_account_and_one_multi_account() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account2 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let different_hd_path: DerivationPath = "m".parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = LoginId::new("second".to_string()); + + // Store the first account + store_login_at_file( + &wallet_file, + account1.clone(), + hd_path.clone(), + id1.clone(), + &password, + ) + .unwrap(); + + let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); + let acc = loaded_login.as_mnemonic_account().unwrap(); + assert_eq!(&account1, acc.mnemonic()); + assert_eq!(&hd_path, acc.hd_path()); + + // Add an extra account + store_login_with_multiple_accounts_at_file( + &wallet_file, + account2.clone(), + different_hd_path.clone(), + id2.clone(), + &password, + ) + .unwrap(); + + // first account should be unchanged + let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); + let acc1 = loaded_login.as_mnemonic_account().unwrap(); + assert_eq!(&account1, acc1.mnemonic()); + assert_eq!(&hd_path, acc1.hd_path()); + + let loaded_login = load_existing_login_at_file(&wallet_file, &id2, &password).unwrap(); + let acc2 = loaded_login.as_multiple_accounts().unwrap(); + assert_eq!(acc2.len(), 1); + let account = acc2 + .get_account(&DEFAULT_FIRST_ACCOUNT_NAME.into()) + .unwrap(); + assert_eq!(account.id().as_ref(), DEFAULT_FIRST_ACCOUNT_NAME); + assert_eq!(account.mnemonic(), &account2); + assert_eq!(account.hd_path(), &different_hd_path); + } + + #[test] + fn remove_non_existent_id_fails() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = LoginId::new("second".to_string()); + + store_login_with_multiple_accounts_at_file(&wallet_file, account1, hd_path, id1, &password) + .unwrap(); // Fails to delete non-existent id in the wallet - let id3 = WalletAccountId::new("phony".to_string()); assert!(matches!( - remove_wallet_login_information_at_file(wallet_file.clone(), &id3), - Err(BackendError::NoSuchIdInWallet), + remove_login_at_file(&wallet_file, &id2), + Err(BackendError::WalletNoSuchLoginId), )); + } + + #[test] + fn store_and_remove_wallet_login_information() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account2 = bip39::Mnemonic::generate(24).unwrap(); + let cosmos_hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let different_hd_path: DerivationPath = "m".parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = LoginId::new("second".to_string()); + + // Store two accounts with two different passwords + store_login_at_file( + &wallet_file, + account1.clone(), + cosmos_hd_path.clone(), + id1.clone(), + &password, + ) + .unwrap(); + store_login_at_file( + &wallet_file, + account2.clone(), + different_hd_path.clone(), + id2.clone(), + &password, + ) + .unwrap(); + + // Load and compare + let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); + let acc1 = loaded_login.as_mnemonic_account().unwrap(); + assert_eq!(&account1, acc1.mnemonic()); + assert_eq!(&cosmos_hd_path, acc1.hd_path()); + + let loaded_login = load_existing_login_at_file(&wallet_file, &id2, &password).unwrap(); + let acc2 = loaded_login.as_mnemonic_account().unwrap(); + assert_eq!(&account2, acc2.mnemonic()); + assert_eq!(&different_hd_path, acc2.hd_path()); // Delete the second account - remove_wallet_login_information_at_file(wallet_file.clone(), &id2).unwrap(); + remove_login_at_file(&wallet_file, &id2).unwrap(); // The first account should be unchanged - let loaded_account = - load_existing_wallet_login_information_at_file(wallet_file.clone(), &id1, &password).unwrap(); - let StoredAccount::Mnemonic(ref acc1) = loaded_account; - assert_eq!(&dummy_account1, acc1.mnemonic()); + let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); + let acc1 = loaded_login.as_mnemonic_account().unwrap(); + assert_eq!(&account1, acc1.mnemonic()); assert_eq!(&cosmos_hd_path, acc1.hd_path()); + // And we can't load the second one anymore + assert!(matches!( + load_existing_login_at_file(&wallet_file, &id2, &password), + Err(BackendError::WalletNoSuchLoginId), + )); + // Delete the first account assert!(wallet_file.exists()); - remove_wallet_login_information_at_file(wallet_file.clone(), &id1).unwrap(); + remove_login_at_file(&wallet_file, &id1).unwrap(); // The file should now be removed assert!(!wallet_file.exists()); + + // And trying to load when the file is gone fails + assert!(matches!( + load_existing_login_at_file(&wallet_file, &id1, &password), + Err(BackendError::WalletFileNotFound), + )); } + #[test] + fn append_account_converts_the_type() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account2 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = AccountId::new("second".to_string()); + + store_login_at_file( + &wallet_file, + account1.clone(), + hd_path.clone(), + id1.clone(), + &password, + ) + .unwrap(); + + // Check that it's there as the correct non-multiple type + let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); + let acc = loaded_login.as_mnemonic_account().unwrap(); + assert_eq!(acc.mnemonic(), &account1); + assert_eq!(acc.hd_path(), &hd_path); + + append_account_to_login_at_file( + &wallet_file, + account2.clone(), + hd_path.clone(), + id1.clone(), + id2.clone(), + &password, + ) + .unwrap(); + + // Check that it is now multiple mnemonic type + let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); + let loaded_accounts = loaded_login.as_multiple_accounts().unwrap(); + let expected = vec![ + WalletAccount::new(id1.into(), MnemonicAccount::new(account1, hd_path.clone())), + WalletAccount::new(id2, MnemonicAccount::new(account2, hd_path)), + ] + .into(); + assert_eq!(loaded_accounts, &expected); + } + + #[test] + fn append_accounts_to_existing_login() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account2 = bip39::Mnemonic::generate(24).unwrap(); + let account3 = bip39::Mnemonic::generate(24).unwrap(); + let account4 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = LoginId::new("second".to_string()); + let id3 = AccountId::new("third".to_string()); + let id4 = AccountId::new("fourth".to_string()); + + store_login_at_file( + &wallet_file, + account1.clone(), + hd_path.clone(), + id1.clone(), + &password, + ) + .unwrap(); + + store_login_at_file( + &wallet_file, + account2.clone(), + hd_path.clone(), + id2.clone(), + &password, + ) + .unwrap(); + + // Check that it's there as the correct non-multiple type + let loaded_login = load_existing_login_at_file(&wallet_file, &id2, &password).unwrap(); + let acc2 = loaded_login.as_mnemonic_account().unwrap(); + assert_eq!(acc2.mnemonic(), &account2); + assert_eq!(acc2.hd_path(), &hd_path); + + // Add a third and fourth mnenonic grouped together with the second one + append_account_to_login_at_file( + &wallet_file, + account3.clone(), + hd_path.clone(), + id2.clone(), + id3.clone(), + &password, + ) + .unwrap(); + append_account_to_login_at_file( + &wallet_file, + account4.clone(), + hd_path.clone(), + id2.clone(), + id4.clone(), + &password, + ) + .unwrap(); + + // Check that we can load all four + let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); + let acc1 = loaded_login.as_mnemonic_account().unwrap(); + assert_eq!(acc1.mnemonic(), &account1); + assert_eq!(acc1.hd_path(), &hd_path); + + let loaded_login = load_existing_login_at_file(&wallet_file, &id2, &password).unwrap(); + let loaded_accounts = loaded_login.as_multiple_accounts().unwrap(); + let expected = vec![ + WalletAccount::new(id2.into(), MnemonicAccount::new(account2, hd_path.clone())), + WalletAccount::new(id3, MnemonicAccount::new(account3, hd_path.clone())), + WalletAccount::new(id4, MnemonicAccount::new(account4, hd_path)), + ] + .into(); + assert_eq!(loaded_accounts, &expected); + } + + #[test] + fn append_accounts_to_existing_login_with_multi() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account2 = bip39::Mnemonic::generate(24).unwrap(); + let account3 = bip39::Mnemonic::generate(24).unwrap(); + let account4 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = LoginId::new("second".to_string()); + let id3 = AccountId::new("third".to_string()); + let id4 = AccountId::new("fourth".to_string()); + + store_login_with_multiple_accounts_at_file( + &wallet_file, + account1.clone(), + hd_path.clone(), + id1.clone(), + &password, + ) + .unwrap(); + + store_login_with_multiple_accounts_at_file( + &wallet_file, + account2.clone(), + hd_path.clone(), + id2.clone(), + &password, + ) + .unwrap(); + + // Add a third and fourth mnenonic grouped together with the second one + append_account_to_login_at_file( + &wallet_file, + account3.clone(), + hd_path.clone(), + id2.clone(), + id3.clone(), + &password, + ) + .unwrap(); + append_account_to_login_at_file( + &wallet_file, + account4.clone(), + hd_path.clone(), + id2.clone(), + id4.clone(), + &password, + ) + .unwrap(); + + // Check that we can load all four + let loaded_login = load_existing_login_at_file(&wallet_file, &id1, &password).unwrap(); + let loaded_accounts = loaded_login.as_multiple_accounts().unwrap(); + let expected = vec![WalletAccount::new( + DEFAULT_FIRST_ACCOUNT_NAME.into(), + MnemonicAccount::new(account1, hd_path.clone()), + )] + .into(); + assert_eq!(loaded_accounts, &expected); + + let loaded_login = load_existing_login_at_file(&wallet_file, &id2, &password).unwrap(); + let loaded_accounts = loaded_login.as_multiple_accounts().unwrap(); + let expected = vec![ + WalletAccount::new( + DEFAULT_FIRST_ACCOUNT_NAME.into(), + MnemonicAccount::new(account2, hd_path.clone()), + ), + WalletAccount::new(id3, MnemonicAccount::new(account3, hd_path.clone())), + WalletAccount::new(id4, MnemonicAccount::new(account4, hd_path)), + ] + .into(); + assert_eq!(loaded_accounts, &expected); + } + + #[test] + fn delete_the_same_account_twice_for_a_login_fails() { + let store_dir = tempdir().unwrap(); + let wallet = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account2 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = AccountId::new("second".to_string()); + + store_login_at_file(&wallet, account1, hd_path.clone(), id1.clone(), &password).unwrap(); + + append_account_to_login_at_file( + &wallet, + account2, + hd_path, + id1.clone(), + id2.clone(), + &password, + ) + .unwrap(); + + remove_account_from_login_at_file(&wallet, &id1, &id2, &password).unwrap(); + + assert!(matches!( + remove_account_from_login_at_file(&wallet, &id1, &id2, &password), + Err(BackendError::WalletNoSuchAccountIdInWalletLogin), + )); + } + + #[test] + fn delete_the_same_account_twice_for_a_login_fails_with_multi() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account2 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = AccountId::new("second".to_string()); + + store_login_with_multiple_accounts_at_file( + &wallet_file, + account1, + hd_path.clone(), + id1.clone(), + &password, + ) + .unwrap(); + + append_account_to_login_at_file( + &wallet_file, + account2, + hd_path, + id1.clone(), + id2.clone(), + &password, + ) + .unwrap(); + + remove_account_from_login_at_file(&wallet_file, &id1, &id2, &password).unwrap(); + + assert!(matches!( + remove_account_from_login_at_file(&wallet_file, &id1, &id2, &password), + Err(BackendError::WalletNoSuchAccountIdInWalletLogin), + )); + } + + #[test] + fn delete_appended_account_doesnt_affect_others() { + let store_dir = tempdir().unwrap(); + let wallet_file = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account2 = bip39::Mnemonic::generate(24).unwrap(); + let account3 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = LoginId::new("second".to_string()); + let id3 = AccountId::new("third".to_string()); + + store_login_at_file( + &wallet_file, + account1, + hd_path.clone(), + id1.clone(), + &password, + ) + .unwrap(); + + store_login_at_file( + &wallet_file, + account2.clone(), + hd_path.clone(), + id2.clone(), + &password, + ) + .unwrap(); + + append_account_to_login_at_file( + &wallet_file, + account3.clone(), + hd_path.clone(), + id2.clone(), + id3.clone(), + &password, + ) + .unwrap(); + + remove_login_at_file(&wallet_file, &id1).unwrap(); + + // The second login one is still there + let loaded_login = load_existing_login_at_file(&wallet_file, &id2, &password).unwrap(); + let loaded_accounts = loaded_login.as_multiple_accounts().unwrap(); + let expected = vec![ + WalletAccount::new(id2.into(), MnemonicAccount::new(account2, hd_path.clone())), + WalletAccount::new(id3, MnemonicAccount::new(account3, hd_path)), + ] + .into(); + assert_eq!(loaded_accounts, &expected); + } + + #[test] + fn remove_all_accounts_for_a_login_removes_the_file_when_empty() { + let store_dir = tempdir().unwrap(); + let wallet = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account2 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = AccountId::new("second".to_string()); + + store_login_with_multiple_accounts_at_file( + &wallet, + account1, + hd_path.clone(), + id1.clone(), + &password, + ) + .unwrap(); + + append_account_to_login_at_file( + &wallet, + account2, + hd_path, + id1.clone(), + id2.clone(), + &password, + ) + .unwrap(); + + remove_account_from_login_at_file(&wallet, &id1, &DEFAULT_FIRST_ACCOUNT_NAME.into(), &password) + .unwrap(); + remove_account_from_login_at_file(&wallet, &id1, &id2, &password).unwrap(); + + // The file should now be removed + assert!(!wallet.exists()); + + // And trying to load when the file is gone fails + assert!(matches!( + load_existing_login_at_file(&wallet, &id1, &password), + Err(BackendError::WalletFileNotFound), + )); + } + + #[test] + fn remove_all_accounts_for_a_login_removes_that_login() { + let store_dir = tempdir().unwrap(); + let wallet = store_dir.path().join(WALLET_INFO_FILENAME); + let account1 = bip39::Mnemonic::generate(24).unwrap(); + let account2 = bip39::Mnemonic::generate(24).unwrap(); + let account3 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = AccountId::new("second".to_string()); + let id3 = LoginId::new("third".to_string()); + + store_login_with_multiple_accounts_at_file( + &wallet, + account1, + hd_path.clone(), + id1.clone(), + &password, + ) + .unwrap(); + + append_account_to_login_at_file( + &wallet, + account2, + hd_path.clone(), + id1.clone(), + id2.clone(), + &password, + ) + .unwrap(); + + store_login_with_multiple_accounts_at_file( + &wallet, + account3.clone(), + hd_path.clone(), + id3.clone(), + &password, + ) + .unwrap(); + + remove_account_from_login_at_file(&wallet, &id1, &DEFAULT_FIRST_ACCOUNT_NAME.into(), &password) + .unwrap(); + remove_account_from_login_at_file(&wallet, &id1, &id2, &password).unwrap(); + + // And trying to load when the file is gone fails + assert!(matches!( + load_existing_login_at_file(&wallet, &id1, &password), + Err(BackendError::WalletNoSuchLoginId), + )); + + // The other login is still there + let loaded_login = load_existing_login_at_file(&wallet, &id3, &password).unwrap(); + let acc3 = loaded_login.as_multiple_accounts().unwrap(); + let expected = vec![WalletAccount::new( + DEFAULT_FIRST_ACCOUNT_NAME.into(), + MnemonicAccount::new(account3, hd_path), + )] + .into(); + assert_eq!(acc3, &expected); + } + + #[test] + fn append_accounts_and_remove_appended_accounts() { + let store_dir = tempdir().unwrap(); + let wallet = store_dir.path().join(WALLET_INFO_FILENAME); + let acc1 = bip39::Mnemonic::generate(24).unwrap(); + let acc2 = bip39::Mnemonic::generate(24).unwrap(); + let acc3 = bip39::Mnemonic::generate(24).unwrap(); + let acc4 = bip39::Mnemonic::generate(24).unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let password = UserPassword::new("password".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = LoginId::new("second".to_string()); + let id3 = AccountId::new("third".to_string()); + let id4 = AccountId::new("fourth".to_string()); + + store_login_at_file( + &wallet, + acc1.clone(), + hd_path.clone(), + id1.clone(), + &password, + ) + .unwrap(); + + store_login_at_file( + &wallet, + acc2.clone(), + hd_path.clone(), + id2.clone(), + &password, + ) + .unwrap(); + + // Add a third and fourth mnenonic grouped together with the second one + append_account_to_login_at_file( + &wallet, + acc3, + hd_path.clone(), + id2.clone(), + id3.clone(), + &password, + ) + .unwrap(); + append_account_to_login_at_file( + &wallet, + acc4.clone(), + hd_path.clone(), + id2.clone(), + id4.clone(), + &password, + ) + .unwrap(); + + // Delete the third mnemonic, from the second login entry + remove_account_from_login_at_file(&wallet, &id2, &id3, &password).unwrap(); + + // Check that we can still load the other accounts + let loaded_login = load_existing_login_at_file(&wallet, &id2, &password).unwrap(); + let loaded_accounts = loaded_login.as_multiple_accounts().unwrap(); + let expected = vec![ + WalletAccount::new( + id2.clone().into(), + MnemonicAccount::new(acc2, hd_path.clone()), + ), + WalletAccount::new(id4.clone(), MnemonicAccount::new(acc4, hd_path.clone())), + ] + .into(); + assert_eq!(loaded_accounts, &expected); + + // Delete the second and fourth mnemonic from the second login entry removes the login entry + remove_account_from_login_at_file(&wallet, &id2, &id2.clone().into(), &password).unwrap(); + remove_account_from_login_at_file(&wallet, &id2, &id4, &password).unwrap(); + assert!(matches!( + load_existing_login_at_file(&wallet, &id2, &password), + Err(BackendError::WalletNoSuchLoginId), + )); + + // The first login is still available + let loaded_login = load_existing_login_at_file(&wallet, &id1, &password).unwrap(); + let account = loaded_login.as_mnemonic_account().unwrap(); + assert_eq!(account.mnemonic(), &acc1); + assert_eq!(account.hd_path(), &hd_path); + } + + // Test to that decrypts a stored file from the repo, to make sure we are able to decrypt stored + // wallets created with older versions. #[test] fn decrypt_stored_wallet() { const SAVED_WALLET: &str = "src/wallet_storage/test-data/saved-wallet.json"; let wallet_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(SAVED_WALLET); - let wallet = load_existing_wallet_at_file(wallet_file).unwrap(); + let wallet = load_existing_wallet_at_file(&wallet_file).unwrap(); - let cosmos_hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); + let hd_path: DerivationPath = COSMOS_DERIVATION_PATH.parse().unwrap(); let password = UserPassword::new("password".to_string()); let bad_password = UserPassword::new("bad-password".to_string()); - let id1 = WalletAccountId::new("first".to_string()); - let id2 = WalletAccountId::new("second".to_string()); + let id1 = LoginId::new("first".to_string()); + let id2 = LoginId::new("second".to_string()); assert!(!wallet.password_can_decrypt_all(&bad_password)); assert!(wallet.password_can_decrypt_all(&password)); - let account1 = wallet.decrypt_account(&id1, &password).unwrap(); - let account2 = wallet.decrypt_account(&id2, &password).unwrap(); + let acc1 = wallet.decrypt_login(&id1, &password).unwrap(); + let acc2 = wallet.decrypt_login(&id2, &password).unwrap(); - let expected_account1 = bip39::Mnemonic::from_str("country mean universe text phone begin deputy reject result good cram illness common cluster proud swamp digital patrol spread bar face december base kick").unwrap(); - let expected_account2 = bip39::Mnemonic::from_str("home mansion start quiz dress decide hint second dragon sunny juice always steak real minimum art rival skin draw total pulp foot goddess agent").unwrap(); + assert!(matches!(acc1, StoredLogin::Mnemonic(_))); + assert!(matches!(acc2, StoredLogin::Mnemonic(_))); - assert_eq!(account1.mnemonic(), &expected_account1); - assert_eq!(account2.mnemonic(), &expected_account2); + let expected_acc1 = bip39::Mnemonic::from_str("country mean universe text phone begin deputy reject result good cram illness common cluster proud swamp digital patrol spread bar face december base kick").unwrap(); + let expected_acc2 = bip39::Mnemonic::from_str("home mansion start quiz dress decide hint second dragon sunny juice always steak real minimum art rival skin draw total pulp foot goddess agent").unwrap(); - let StoredAccount::Mnemonic(ref mnemonic1) = account1; - assert_eq!(mnemonic1.mnemonic(), &expected_account1); - assert_eq!(mnemonic1.hd_path(), &cosmos_hd_path); + assert_eq!( + acc1.as_mnemonic_account().unwrap().mnemonic(), + &expected_acc1 + ); + assert_eq!(acc1.as_mnemonic_account().unwrap().hd_path(), &hd_path,); - let StoredAccount::Mnemonic(ref mnemonic2) = account2; - assert_eq!(mnemonic2.mnemonic(), &expected_account2); - assert_eq!(mnemonic2.hd_path(), &cosmos_hd_path); + assert_eq!( + acc2.as_mnemonic_account().unwrap().mnemonic(), + &expected_acc2 + ); + assert_eq!(acc2.as_mnemonic_account().unwrap().hd_path(), &hd_path,); + } + + #[test] + fn decrypt_stored_wallet_with_multiple_accounts() { + // WIP(JON) } } diff --git a/nym-wallet/src-tauri/src/wallet_storage/password.rs b/nym-wallet/src-tauri/src/wallet_storage/password.rs index 04b85277a1..8701f8994d 100644 --- a/nym-wallet/src-tauri/src/wallet_storage/password.rs +++ b/nym-wallet/src-tauri/src/wallet_storage/password.rs @@ -6,22 +6,75 @@ use std::fmt; use serde::{Deserialize, Serialize}; use zeroize::Zeroize; -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct WalletAccountId(String); +// The `LoginId` is the top level id in the wallet file, and is not stored encrypted +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Zeroize)] +pub(crate) struct LoginId(String); -impl WalletAccountId { - pub(crate) fn new(id: String) -> WalletAccountId { - WalletAccountId(id) +impl LoginId { + pub(crate) fn new(id: String) -> LoginId { + LoginId(id) } } -impl AsRef for WalletAccountId { +impl AsRef for LoginId { fn as_ref(&self) -> &str { self.0.as_ref() } } -impl fmt::Display for WalletAccountId { +impl From for LoginId { + fn from(id: String) -> Self { + Self::new(id) + } +} + +impl From<&str> for LoginId { + fn from(id: &str) -> Self { + Self::new(id.to_string()) + } +} + +impl fmt::Display for LoginId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +// For each encrypted login, we can have multiple encrypted accounts. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Zeroize)] +pub(crate) struct AccountId(String); + +impl AccountId { + pub(crate) fn new(id: String) -> AccountId { + AccountId(id) + } +} + +impl AsRef for AccountId { + fn as_ref(&self) -> &str { + self.0.as_ref() + } +} + +impl From for AccountId { + fn from(id: String) -> Self { + Self::new(id) + } +} + +impl From<&str> for AccountId { + fn from(id: &str) -> Self { + Self::new(id.to_string()) + } +} + +impl From for AccountId { + fn from(login_id: LoginId) -> Self { + Self::new(login_id.0) + } +} + +impl fmt::Display for AccountId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.0) } diff --git a/nym-wallet/src/components/Accounts/AccountAvatar.tsx b/nym-wallet/src/components/Accounts/AccountAvatar.tsx new file mode 100644 index 0000000000..95d1fcfd16 --- /dev/null +++ b/nym-wallet/src/components/Accounts/AccountAvatar.tsx @@ -0,0 +1,8 @@ +import React from 'react'; +import { Avatar } from '@mui/material'; +import stc from 'string-to-color'; +import { TAccount } from 'src/types'; + +export const AccountAvatar = ({ name }: Pick) => ( + {name?.split('')[0]} +); diff --git a/nym-wallet/src/components/Accounts/AccountItem.tsx b/nym-wallet/src/components/Accounts/AccountItem.tsx new file mode 100644 index 0000000000..8344d355a4 --- /dev/null +++ b/nym-wallet/src/components/Accounts/AccountItem.tsx @@ -0,0 +1,68 @@ +import React, { useContext } from 'react'; +import { Box, ListItem, ListItemAvatar, ListItemButton, ListItemText, Tooltip, Typography } from '@mui/material'; +import { useClipboard } from 'use-clipboard-copy'; +import { AccountsContext } from 'src/context'; +import { AccountAvatar } from './AccountAvatar'; + +export const AccountItem = ({ name, address }: { name: string; address: string }) => { + const { selectedAccount, handleSelectAccount, setDialogToDisplay, setAccountMnemonic } = useContext(AccountsContext); + const { copy, copied } = useClipboard({ copiedTimeout: 1000 }); + return ( + + handleSelectAccount(name)}> + + + + + + ) => { + e.stopPropagation(); + copy(address); + }} + sx={{ '&:hover': { color: 'grey.900' } }} + > + {address} + + + + ) => { + e.stopPropagation(); + setDialogToDisplay('Mnemonic'); + setAccountMnemonic((accountMnemonic) => ({ ...accountMnemonic, accountName: name })); + }} + > + Show mnemonic + + + + } + /> + {/* edit and remove accounts todo */} + {/* + { + e.stopPropagation(); + handleAccountToEdit(name); + }} + > + + + */} + + + ); +}; diff --git a/nym-wallet/src/components/Accounts/AccountOverview.tsx b/nym-wallet/src/components/Accounts/AccountOverview.tsx new file mode 100644 index 0000000000..17830ba319 --- /dev/null +++ b/nym-wallet/src/components/Accounts/AccountOverview.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { Button } from '@mui/material'; +import { AccountEntry } from 'src/types'; +import { AccountAvatar } from './AccountAvatar'; + +export const AccountOverview = ({ account, onClick }: { account: AccountEntry; onClick: () => void }) => ( + +); diff --git a/nym-wallet/src/components/Accounts/Accounts.tsx b/nym-wallet/src/components/Accounts/Accounts.tsx new file mode 100644 index 0000000000..a59b59c95f --- /dev/null +++ b/nym-wallet/src/components/Accounts/Accounts.tsx @@ -0,0 +1,36 @@ +import React, { useContext, useState } from 'react'; +import { AccountsContext, AppContext } from 'src/context'; +import { EditAccountModal } from './EditAccountModal'; +import { AddAccountModal } from './AddAccountModal'; +import { AccountsModal } from './AccountsModal'; +import { MnemonicModal } from './MnemonicModal'; +import { AccountOverview } from './AccountOverview'; +import { MultiAccountHowTo } from './MultiAccountHowTo'; + +export const Accounts = () => { + const { accounts, selectedAccount, setDialogToDisplay } = useContext(AccountsContext); + + return accounts && selectedAccount ? ( + <> + setDialogToDisplay('Accounts')} /> + + + + + + ) : null; +}; + +export const SingleAccount = () => { + const [showHowToDialog, setShowHowToDialog] = useState(false); + const { clientDetails } = useContext(AppContext); + return ( + <> + setShowHowToDialog(true)} + /> + setShowHowToDialog(false)} /> + + ); +}; diff --git a/nym-wallet/src/components/Accounts/AccountsModal.tsx b/nym-wallet/src/components/Accounts/AccountsModal.tsx new file mode 100644 index 0000000000..8210b3c562 --- /dev/null +++ b/nym-wallet/src/components/Accounts/AccountsModal.tsx @@ -0,0 +1,48 @@ +import React, { useContext } from 'react'; +import { Box, Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, Typography } from '@mui/material'; +import { Add, ArrowDownwardSharp, Close } from '@mui/icons-material'; +import { AccountsContext } from 'src/context'; +import { AccountItem } from './AccountItem'; + +export const AccountsModal = ({ onClose }: { onClose?: () => void }) => { + const { accounts, dialogToDisplay, setDialogToDisplay } = useContext(AccountsContext); + + const handleClose = () => { + setDialogToDisplay(undefined); + onClose?.(); + }; + + return ( + + + + Accounts + + + + + + Switch between accounts + + + + {accounts?.map(({ id, address }) => ( + + ))} + + + + + + + ); +}; diff --git a/nym-wallet/src/components/Accounts/AddAccountModal.tsx b/nym-wallet/src/components/Accounts/AddAccountModal.tsx new file mode 100644 index 0000000000..3f3aec7b62 --- /dev/null +++ b/nym-wallet/src/components/Accounts/AddAccountModal.tsx @@ -0,0 +1,282 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { ArrowBackSharp } from '@mui/icons-material'; +import { useClipboard } from 'use-clipboard-copy'; +import { createMnemonic, validateMnemonic } from 'src/requests'; +import { AccountsContext } from 'src/context'; +import { PasswordInput } from 'src/components'; +import { Mnemonic } from '../Mnemonic'; +import { Console } from 'src/utils/console'; +import { match } from 'assert'; + +const createAccountSteps = [ + 'Copy and save mnemonic for your new account', + 'Name your new account', + 'Confirm the password used to login to your wallet', +]; +const importAccountSteps = [ + 'Provide mnemonic of account you want to import', + 'Name your new account', + 'Confirm the password used to login to your wallet', +]; + +const MnemonicStep = ({ mnemonic, onNext }: { mnemonic: string; onNext: () => void }) => { + const { copy, copied } = useClipboard({ copiedTimeout: 5000 }); + return ( + + + + + + + + + ); +}; + +const ImportMnemonic = ({ + value, + onChange, + onNext, +}: { + value: string; + onChange: (value: string) => void; + onNext: () => void; +}) => { + const [error, setError] = useState(); + + const handleOnNext = async () => { + const isValid = await validateMnemonic(value); + if (!isValid) setError('Please enter a valid mnemonic. Mnemonic must have a word count that is a multiple of 6.'); + else onNext(); + }; + + return ( + + + + {error && ( + + {error} + + )} + { + onChange(e.target.value); + setError(undefined); + }} + fullWidth + type="password" + /> + + + + + + + ); +}; + +const NameAccount = ({ onNext }: { onNext: (value: string) => void }) => { + const [value, setValue] = useState(''); + const [error, setError] = useState(); + + const nameValidation = /^([a-zA-Z0-9\s]){1,20}$/; + + const handleNext = (value: string) => { + if (!nameValidation.test(value)) + [setError('Account name must contain only letters and numbers and be between 1 and 20 characters')]; + else onNext(value); + }; + + return ( + + + + {error} + + { + setValue(e.target.value); + setError(undefined); + }} + fullWidth + /> + + + + + + ); +}; + +const ConfirmPassword = ({ onConfirm }: { onConfirm: (password: string) => void }) => { + const [value, setValue] = useState(''); + const { isLoading, error } = useContext(AccountsContext); + + return ( + + + {error && ( + + {error} + + )} + setValue(pswrd)} + label="Confirm password" + autoFocus + /> + + + + + + ); +}; + +export const AddAccountModal = () => { + const [step, setStep] = useState(0); + const [data, setData] = useState({ + mnemonic: '', + accountName: '', + }); + + const { dialogToDisplay, setDialogToDisplay, handleAddAccount, setError } = useContext(AccountsContext); + + const generateMnemonic = async () => { + const mnemon = await createMnemonic(); + setData((d) => ({ ...d, mnemonic: mnemon })); + }; + + const handleClose = () => { + setDialogToDisplay('Accounts'); + resetState(); + }; + + const resetState = () => { + setData({ mnemonic: '', accountName: '' }); + setStep(0); + setError(undefined); + }; + + useEffect(() => { + if (dialogToDisplay === 'Add') generateMnemonic(); + if (dialogToDisplay === 'Accounts') resetState(); + }, [dialogToDisplay]); + + useEffect(() => { + setError(undefined); + }, [step]); + + return ( + + + + {`${dialogToDisplay} new account`} + (step === 0 ? handleClose() : setStep((s) => s - 1))}> + + + + + {dialogToDisplay === 'Add' ? createAccountSteps[step] : importAccountSteps[step]} + + + {(() => { + switch (step) { + case 0: + return dialogToDisplay === 'Add' ? ( + setStep((s) => s + 1)} /> + ) : ( + setData((d) => ({ ...d, mnemonic: value }))} + onNext={() => setStep((s) => s + 1)} + /> + ); + case 1: + return ( + { + setData((d) => ({ ...d, accountName })); + setStep((s) => s + 1); + }} + /> + ); + case 2: + return ( + { + if (data.accountName && data.mnemonic) { + try { + await handleAddAccount({ accountName: data.accountName, mnemonic: data.mnemonic, password }); + setStep(0); + setDialogToDisplay('Accounts'); + } catch (e) { + Console.error(e as string); + } + } + }} + /> + ); + default: + return null; + } + })()} + + ); +}; diff --git a/nym-wallet/src/components/Accounts/EditAccountModal.tsx b/nym-wallet/src/components/Accounts/EditAccountModal.tsx new file mode 100644 index 0000000000..c5fec1d28b --- /dev/null +++ b/nym-wallet/src/components/Accounts/EditAccountModal.tsx @@ -0,0 +1,68 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + TextField, + Typography, +} from '@mui/material'; +import { Close } from '@mui/icons-material'; +import { AccountsContext } from 'src/context'; + +export const EditAccountModal = () => { + const [accountName, setAccountName] = useState(''); + + const { accountToEdit, dialogToDisplay, setDialogToDisplay, handleEditAccount } = useContext(AccountsContext); + + useEffect(() => { + setAccountName(accountToEdit ? accountToEdit?.id : ''); + }, [accountToEdit]); + + return ( + setDialogToDisplay('Accounts')} fullWidth hideBackdrop> + + + Edit account name + setDialogToDisplay('Accounts')}> + + + + + New wallet address + + + + + setAccountName(e.target.value)} + autoFocus + /> + + + + + + + ); +}; diff --git a/nym-wallet/src/components/Accounts/ImportAccountModal.tsx b/nym-wallet/src/components/Accounts/ImportAccountModal.tsx new file mode 100644 index 0000000000..2e61066f71 --- /dev/null +++ b/nym-wallet/src/components/Accounts/ImportAccountModal.tsx @@ -0,0 +1,66 @@ +import React, { useContext, useState } from 'react'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + TextField, + Typography, +} from '@mui/material'; +import { Close } from '@mui/icons-material'; +import { AccountsContext } from 'src/context'; + +export const ImportAccountModal = () => { + const [mnemonic, setMnemonic] = useState(''); + + const { dialogToDisplay, setDialogToDisplay, handleImportAccount } = useContext(AccountsContext); + + const handleClose = () => { + setMnemonic(''); + setDialogToDisplay('Accounts'); + }; + + return ( + + + + Import account + + + + + + Provide mnemonic of account you want to import + + + + + setMnemonic(e.target.value)} + autoFocus + multiline + rows={3} + /> + + + + + + + ); +}; diff --git a/nym-wallet/src/components/Accounts/MnemonicModal.tsx b/nym-wallet/src/components/Accounts/MnemonicModal.tsx new file mode 100644 index 0000000000..cecc572a73 --- /dev/null +++ b/nym-wallet/src/components/Accounts/MnemonicModal.tsx @@ -0,0 +1,100 @@ +import React, { useContext, useState } from 'react'; +import { + Box, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Typography, +} from '@mui/material'; +import { ArrowBackSharp } from '@mui/icons-material'; +import { AccountsContext } from 'src/context'; +import { useClipboard } from 'use-clipboard-copy'; +import { Mnemonic } from '../Mnemonic'; +import { PasswordInput } from '../textfields'; + +export const MnemonicModal = () => { + const [password, setPassword] = useState(''); + + const { copy, copied } = useClipboard({ copiedTimeout: 5000 }); + + const { + dialogToDisplay, + setDialogToDisplay, + accountMnemonic, + setAccountMnemonic, + handleGetAcccountMnemonic, + error, + setError, + isLoading, + } = useContext(AccountsContext); + + const handleClose = () => { + setAccountMnemonic({ value: undefined, accountName: undefined }); + setError(undefined); + setDialogToDisplay('Accounts'); + setPassword(''); + }; + + return ( + + + + Display mnemonic + + + + + + {`Display mnemonic for: ${accountMnemonic?.accountName}`} + + + + + {error && ( + + {error} + + )} + {!accountMnemonic.value ? ( + <> + Enter the password used to login to your wallet + setPassword(pswrd)} + autoFocus + /> + + ) : ( + + )} + + + + {!accountMnemonic.value && ( + + )} + + + ); +}; diff --git a/nym-wallet/src/components/Accounts/MultiAccountHowTo.tsx b/nym-wallet/src/components/Accounts/MultiAccountHowTo.tsx new file mode 100644 index 0000000000..739f2ff8ea --- /dev/null +++ b/nym-wallet/src/components/Accounts/MultiAccountHowTo.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Alert, Box, Dialog, DialogContent, DialogTitle, IconButton, Stack, Typography } from '@mui/material'; +import { Close } from '@mui/icons-material'; + +const passwordCreationSteps = [ + 'Log out', + 'When signing in, select “Sign in with mnemonic”', + 'On the next screen click “Create a password for your account”', + 'Sign in to wallet with your new password', + 'Now you can create multiple accounts', +]; + +export const MultiAccountHowTo = ({ show, handleClose }: { show: boolean; handleClose: () => void }) => ( + + + + Multi accounts + + + + + + How to set up multiple accounts + + + + + + In order to create multiple accounts your wallet needs a password. + Follow steps below to create password. + + How to create a password for your account + {passwordCreationSteps.map((step, index) => ( + {`${index + 1}. ${step}`} + ))} + + + +); diff --git a/nym-wallet/src/components/Accounts/index.tsx b/nym-wallet/src/components/Accounts/index.tsx new file mode 100644 index 0000000000..9e02c40d2b --- /dev/null +++ b/nym-wallet/src/components/Accounts/index.tsx @@ -0,0 +1,16 @@ +import React, { useContext } from 'react'; +import { AccountsProvider, AppContext } from 'src/context'; +import { Accounts, SingleAccount } from './Accounts'; + +export const MultiAccounts = () => { + const { loginType } = useContext(AppContext); + + if (loginType === 'password') { + return ( + + + + ); + } + return ; +}; diff --git a/nym-wallet/src/components/Accounts/mocks.ts b/nym-wallet/src/components/Accounts/mocks.ts new file mode 100644 index 0000000000..d3505b0b4c --- /dev/null +++ b/nym-wallet/src/components/Accounts/mocks.ts @@ -0,0 +1,10 @@ +export const accounts = [ + { + id: 'Account 1', + address: 'n107wsxkj08hycflnkp5ayfg6rt3pt0psm7w2t9r', + }, + { + id: 'Account 2', + address: 'n1dgp04lqaasnzaww66zwdp6u24smqe7ltuny8vk', + }, +]; diff --git a/nym-wallet/src/components/Accounts/stories/Accounts.stories.tsx b/nym-wallet/src/components/Accounts/stories/Accounts.stories.tsx new file mode 100644 index 0000000000..4c6f6550a9 --- /dev/null +++ b/nym-wallet/src/components/Accounts/stories/Accounts.stories.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { Box } from '@mui/material'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { MockAccountsProvider } from 'src/context/mocks/accounts'; +import { Accounts } from '../Accounts'; + +export default { + title: 'Wallet / Multi Account', + component: Accounts, +} as ComponentMeta; + +export const Default: ComponentStory = () => ( + + + + + +); diff --git a/nym-wallet/src/components/Accounts/types.ts b/nym-wallet/src/components/Accounts/types.ts new file mode 100644 index 0000000000..b73b463940 --- /dev/null +++ b/nym-wallet/src/components/Accounts/types.ts @@ -0,0 +1 @@ +export type TDialog = 'Accounts' | 'Add' | 'Edit' | 'Import'; diff --git a/nym-wallet/src/components/AppBar.tsx b/nym-wallet/src/components/AppBar.tsx index a77ed9ca1e..def6dce2f5 100644 --- a/nym-wallet/src/components/AppBar.tsx +++ b/nym-wallet/src/components/AppBar.tsx @@ -1,21 +1,28 @@ -import React, { useContext, useEffect, useState } from 'react'; +import React, { useContext } from 'react'; import { AppBar as MuiAppBar, Grid, IconButton, Toolbar } from '@mui/material'; +import { useHistory } from 'react-router-dom'; import { Logout } from '@mui/icons-material'; import TerminalIcon from '@mui/icons-material/Terminal'; -import { ClientContext } from '../context/main'; +import { AppContext } from '../context/main'; import { NetworkSelector } from './NetworkSelector'; import { Node as NodeIcon } from '../svg-icons/node'; +import { MultiAccounts } from './Accounts'; import { config } from '../../config'; export const AppBar = () => { - const { showSettings, logOut, handleShowSettings, handleShowTerminal, appEnv } = useContext(ClientContext); - + const { showSettings, logOut, handleShowSettings, handleShowTerminal, appEnv } = useContext(AppContext); + const history = useHistory(); return ( - - + + + + + + + {(appEnv?.SHOW_TERMINAL || config.IS_DEV_MODE) && ( @@ -35,7 +42,14 @@ export const AppBar = () => { - + { + await logOut(); + history.push('/'); + }} + sx={{ color: 'nym.background.dark' }} + > diff --git a/nym-wallet/src/components/ClientAddress.stories.tsx b/nym-wallet/src/components/ClientAddress.stories.tsx index eae99150a7..08a3d873b4 100644 --- a/nym-wallet/src/components/ClientAddress.stories.tsx +++ b/nym-wallet/src/components/ClientAddress.stories.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { ComponentMeta, ComponentStory } from '@storybook/react'; -import { Box, Typography } from '@mui/material'; +import { Box } from '@mui/material'; import { ClientAddressDisplay } from './ClientAddress'; export default { diff --git a/nym-wallet/src/components/ClientAddress.tsx b/nym-wallet/src/components/ClientAddress.tsx index 25c32919db..816f8b6a3a 100644 --- a/nym-wallet/src/components/ClientAddress.tsx +++ b/nym-wallet/src/components/ClientAddress.tsx @@ -1,6 +1,6 @@ import React, { FC, useContext } from 'react'; import { Box, Typography, Tooltip } from '@mui/material'; -import { ClientContext } from '../context/main'; +import { AppContext } from '../context/main'; import { CopyToClipboard } from './CopyToClipboard'; import { splice } from '../utils'; @@ -53,6 +53,6 @@ export const ClientAddressDisplay: FC ); export const ClientAddress: FC = ({ ...props }) => { - const { clientDetails } = useContext(ClientContext); + const { clientDetails } = useContext(AppContext); return ; }; diff --git a/nym-wallet/src/components/Error.tsx b/nym-wallet/src/components/Error.tsx index f582536b6b..a6d042ddc2 100644 --- a/nym-wallet/src/components/Error.tsx +++ b/nym-wallet/src/components/Error.tsx @@ -1,17 +1,8 @@ import React from 'react'; -import { FallbackProps } from 'react-error-boundary'; -import { Alert, AlertTitle, Button } from '@mui/material'; +import { Alert } from '@mui/material'; -export const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => ( -
- - {error.name} - {error.message} - - - Stack trace - {error.stack} - - -
+export const Error = ({ message }: { message: string }) => ( + + {message} + ); diff --git a/nym-wallet/src/components/ErrorFallback.tsx b/nym-wallet/src/components/ErrorFallback.tsx new file mode 100644 index 0000000000..f582536b6b --- /dev/null +++ b/nym-wallet/src/components/ErrorFallback.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { FallbackProps } from 'react-error-boundary'; +import { Alert, AlertTitle, Button } from '@mui/material'; + +export const ErrorFallback = ({ error, resetErrorBoundary }: FallbackProps) => ( +
+ + {error.name} + {error.message} + + + Stack trace + {error.stack} + + +
+); diff --git a/nym-wallet/src/components/Fee.tsx b/nym-wallet/src/components/Fee.tsx index d4a7b550ec..4b2ae45646 100644 --- a/nym-wallet/src/components/Fee.tsx +++ b/nym-wallet/src/components/Fee.tsx @@ -2,11 +2,11 @@ import React, { useState, useEffect, useContext } from 'react'; import { Typography } from '@mui/material'; import { Operation } from '../types'; import { getGasFee } from '../requests'; -import { ClientContext } from '../context/main'; +import { AppContext } from '../context/main'; export const Fee = ({ feeType }: { feeType: Operation }) => { const [fee, setFee] = useState(); - const { currency } = useContext(ClientContext); + const { currency } = useContext(AppContext); const getFee = async () => { const res = await getGasFee(feeType); diff --git a/nym-wallet/src/components/LoadingPage.tsx b/nym-wallet/src/components/LoadingPage.tsx new file mode 100644 index 0000000000..7143bf49a0 --- /dev/null +++ b/nym-wallet/src/components/LoadingPage.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { Box, LinearProgress, Stack } from '@mui/material'; +import { NymWordmark } from '@nymproject/react'; +import { AuthTheme } from 'src/theme'; + +export const LoadingPage = () => ( + + + + + + + + + + + + +); diff --git a/nym-wallet/src/components/Mnemonic.tsx b/nym-wallet/src/components/Mnemonic.tsx new file mode 100644 index 0000000000..e49c2b8042 --- /dev/null +++ b/nym-wallet/src/components/Mnemonic.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { Alert, Button, Stack, TextField, Typography } from '@mui/material'; +import { Check, ContentCopySharp } from '@mui/icons-material'; + +export const Mnemonic = ({ + mnemonic, + copied, + handleCopy, +}: { + mnemonic: string; + copied: boolean; + handleCopy: (text?: string) => void; +}) => ( + + + + Below is your 24 word mnemonic, make sure to store it in a safe place for accessing your wallet in the future + + + + + + +); diff --git a/nym-wallet/src/components/Nav.tsx b/nym-wallet/src/components/Nav.tsx index 90d7e18f4a..91d3cea8a8 100644 --- a/nym-wallet/src/components/Nav.tsx +++ b/nym-wallet/src/components/Nav.tsx @@ -2,7 +2,7 @@ import React, { useContext, useEffect } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { List, ListItem, ListItemIcon, ListItemText } from '@mui/material'; import { AccountBalanceWalletOutlined, ArrowBack, ArrowForward, Description, Settings } from '@mui/icons-material'; -import { ClientContext } from '../context/main'; +import { AppContext } from '../context/main'; import { Bond, Delegate, Unbond, Undelegate } from '../svg-icons'; const routesSchema = [ @@ -44,7 +44,7 @@ const routesSchema = [ ]; export const Nav = () => { - const { isAdminAddress, handleShowAdmin } = useContext(ClientContext); + const { isAdminAddress, handleShowAdmin } = useContext(AppContext); const location = useLocation(); useEffect(() => { diff --git a/nym-wallet/src/components/NetworkSelector.tsx b/nym-wallet/src/components/NetworkSelector.tsx index f0f7c25f78..7febfbc435 100644 --- a/nym-wallet/src/components/NetworkSelector.tsx +++ b/nym-wallet/src/components/NetworkSelector.tsx @@ -1,7 +1,7 @@ import React, { useState, useContext } from 'react'; import { Button, List, ListItem, ListItemIcon, ListItemText, ListSubheader, Popover } from '@mui/material'; import { ArrowDropDown, CheckSharp } from '@mui/icons-material'; -import { ClientContext } from '../context/main'; +import { AppContext } from '../context/main'; import { config } from '../../config'; import { Network } from '../types'; @@ -23,7 +23,7 @@ const NetworkItem: React.FC<{ title: string; isSelected: boolean; onSelect: () = ); export const NetworkSelector = () => { - const { network, switchNetwork } = useContext(ClientContext); + const { network, switchNetwork } = useContext(AppContext); const [anchorEl, setAnchorEl] = useState(null); diff --git a/nym-wallet/src/components/NymCard.tsx b/nym-wallet/src/components/NymCard.tsx index 0009573a3a..e98bb4323f 100644 --- a/nym-wallet/src/components/NymCard.tsx +++ b/nym-wallet/src/components/NymCard.tsx @@ -14,10 +14,11 @@ export const NymCard: React.FC<{ title: string | React.ReactElement; subheader?: string; Action?: React.ReactNode; - Icon?: any; + Icon?: React.ReactNode; noPadding?: boolean; -}> = ({ title, subheader, Action, Icon, noPadding, children }) => ( - + borderless?: boolean; +}> = ({ title, subheader, Action, Icon, noPadding, borderless, children }) => ( + } diff --git a/nym-wallet/src/components/Title.tsx b/nym-wallet/src/components/Title.tsx index ed041f824e..f8dbfcc476 100644 --- a/nym-wallet/src/components/Title.tsx +++ b/nym-wallet/src/components/Title.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { Box, Typography } from '@mui/material'; -export const Title: React.FC<{ title: string | React.ReactNode; Icon: any }> = ({ title, Icon }) => ( +export const Title: React.FC<{ title: string | React.ReactNode; Icon?: React.ReactNode }> = ({ title, Icon }) => ( - {Icon && }{' '} + {Icon} {title} diff --git a/nym-wallet/src/components/TokenPoolSelector.tsx b/nym-wallet/src/components/TokenPoolSelector.tsx index 60e9c9788b..99da52f9dc 100644 --- a/nym-wallet/src/components/TokenPoolSelector.tsx +++ b/nym-wallet/src/components/TokenPoolSelector.tsx @@ -1,6 +1,6 @@ import React, { useContext, useEffect, useState } from 'react'; import { FormControl, InputLabel, ListItemText, MenuItem, Select, SelectChangeEvent, Typography } from '@mui/material'; -import { ClientContext } from '../context/main'; +import { AppContext } from '../context/main'; type TPoolOption = 'balance' | 'locked'; @@ -12,7 +12,7 @@ export const TokenPoolSelector: React.FC<{ disabled: boolean; onSelect: (pool: T const { userBalance: { tokenAllocation, balance, fetchBalance, fetchTokenAllocation }, currency, - } = useContext(ClientContext); + } = useContext(AppContext); useEffect(() => { (async () => { diff --git a/nym-wallet/src/components/index.ts b/nym-wallet/src/components/index.ts index 2b04937757..b688f8a81f 100644 --- a/nym-wallet/src/components/index.ts +++ b/nym-wallet/src/components/index.ts @@ -1,4 +1,4 @@ -export * from './Error'; +export * from './ErrorFallback'; export * from './CopyToClipboard'; export * from './NymCard'; export * from './Nav'; @@ -15,3 +15,5 @@ export * from './ClientAddress'; export * from './InfoToolTip'; export * from './Title'; export * from './TokenPoolSelector'; +export * from './LoadingPage'; +export * from './textfields'; diff --git a/nym-wallet/src/pages/sign-in/components/textfields.tsx b/nym-wallet/src/components/textfields.tsx similarity index 98% rename from nym-wallet/src/pages/sign-in/components/textfields.tsx rename to nym-wallet/src/components/textfields.tsx index bf4e5e5597..835e6beb23 100644 --- a/nym-wallet/src/pages/sign-in/components/textfields.tsx +++ b/nym-wallet/src/components/textfields.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { Box, IconButton, Stack, TextField } from '@mui/material'; import { Visibility, VisibilityOff } from '@mui/icons-material'; -import { Error } from './error'; +import { Error } from './Error'; export const MnemonicInput: React.FC<{ mnemonic: string; diff --git a/nym-wallet/src/context/accounts.tsx b/nym-wallet/src/context/accounts.tsx new file mode 100644 index 0000000000..51794db671 --- /dev/null +++ b/nym-wallet/src/context/accounts.tsx @@ -0,0 +1,135 @@ +import React, { createContext, Dispatch, SetStateAction, useContext, useEffect, useMemo, useState } from 'react'; +import { AccountEntry } from 'src/types'; +import { addAccount as addAccountRequest, showMnemonicForAccount } from 'src/requests'; +import { useSnackbar } from 'notistack'; +import { AppContext } from './main'; + +type TAccounts = { + accounts?: AccountEntry[]; + selectedAccount?: AccountEntry; + accountToEdit?: AccountEntry; + dialogToDisplay?: TAccountsDialog; + isLoading: boolean; + error?: string; + accountMnemonic: TAccountMnemonic; + setError: Dispatch>; + setAccountMnemonic: Dispatch>; + handleAddAccount: (data: { accountName: string; mnemonic: string; password: string }) => void; + setDialogToDisplay: (dialog?: TAccountsDialog) => void; + handleSelectAccount: (accountId: string) => void; + handleAccountToEdit: (accountId: string) => void; + handleEditAccount: (account: AccountEntry) => void; + handleImportAccount: (account: AccountEntry) => void; + handleGetAcccountMnemonic: (data: { password: string; accountName: string }) => void; +}; + +export type TAccountsDialog = 'Accounts' | 'Add' | 'Edit' | 'Import' | 'Mnemonic'; +export type TAccountMnemonic = { value?: string; accountName?: string }; + +export const AccountsContext = createContext({} as TAccounts); + +export const AccountsProvider: React.FC = ({ children }) => { + const [accounts, setAccounts] = useState([]); + const [selectedAccount, setSelectedAccount] = useState(); + const [accountToEdit, setAccountToEdit] = useState(); + const [dialogToDisplay, setDialogToDisplay] = useState(); + const [accountMnemonic, setAccountMnemonic] = useState({ + value: undefined, + accountName: undefined, + }); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(false); + const { onAccountChange, storedAccounts } = useContext(AppContext); + const { enqueueSnackbar } = useSnackbar(); + + const handleAddAccount = async ({ + accountName, + mnemonic, + password, + }: { + accountName: string; + mnemonic: string; + password: string; + }) => { + setIsLoading(true); + try { + const newAccount = await addAccountRequest({ + accountName, + mnemonic, + password, + }); + setAccounts((accs) => [...accs, newAccount]); + enqueueSnackbar('New account created', { variant: 'success' }); + } catch (e) { + setError(`Error adding account: ${e}`); + throw new Error(); + } finally { + setIsLoading(false); + } + }; + const handleEditAccount = (account: AccountEntry) => + setAccounts((accs) => accs?.map((acc) => (acc.address === account.address ? account : acc))); + + const handleImportAccount = (account: AccountEntry) => setAccounts((accs) => [...(accs ? [...accs] : []), account]); + + const handleAccountToEdit = (accountName: string) => + setAccountToEdit(accounts?.find((acc) => acc.id === accountName)); + + const handleSelectAccount = async (accountName: string) => { + if (accountName !== selectedAccount?.id) { + await onAccountChange(accountName); + const match = accounts?.find((acc) => acc.id === accountName); + setSelectedAccount(match); + } + }; + + const handleGetAcccountMnemonic = async ({ password, accountName }: { password: string; accountName: string }) => { + try { + setIsLoading(true); + const mnemonic = await showMnemonicForAccount({ password, accountName }); + setAccountMnemonic({ value: mnemonic, accountName }); + } catch (e) { + setError(e as string); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + if (storedAccounts) { + setAccounts(storedAccounts); + } + + if (storedAccounts && !selectedAccount) { + setSelectedAccount(storedAccounts[0]); + } + }, [storedAccounts]); + + return ( + ({ + error, + setError, + accounts, + selectedAccount, + accountToEdit, + dialogToDisplay, + accountMnemonic, + setDialogToDisplay, + setAccountMnemonic, + isLoading, + handleAddAccount, + handleEditAccount, + handleAccountToEdit, + handleSelectAccount, + handleImportAccount, + handleGetAcccountMnemonic, + }), + [accounts, selectedAccount, accountToEdit, dialogToDisplay, isLoading, error, accountMnemonic], + )} + > + {children} + + ); +}; diff --git a/nym-wallet/src/pages/sign-in/context/index.tsx b/nym-wallet/src/context/auth.tsx similarity index 80% rename from nym-wallet/src/pages/sign-in/context/index.tsx rename to nym-wallet/src/context/auth.tsx index 962c6a7340..12798ef134 100644 --- a/nym-wallet/src/pages/sign-in/context/index.tsx +++ b/nym-wallet/src/context/auth.tsx @@ -1,11 +1,10 @@ import React, { createContext, useEffect, useMemo, useState } from 'react'; -import { useHistory } from 'react-router-dom'; import { createMnemonic } from 'src/requests'; -import { TMnemonicWords } from '../types'; +import { TMnemonicWords } from 'src/pages/auth/types'; -export const SignInContext = createContext({} as TSignInContent); +export const AuthContext = createContext({} as TAuthContext); -export type TSignInContent = { +export type TAuthContext = { error?: string; password: string; mnemonic: string; @@ -22,23 +21,17 @@ const mnemonicToArray = (mnemonic: string): TMnemonicWords => .split(' ') .reduce((a, c: string, index) => [...a, { name: c, index: index + 1, disabled: false }], [] as TMnemonicWords); -export const SignInProvider: React.FC = ({ children }) => { +export const AuthProvider: React.FC = ({ children }) => { const [password, setPassword] = useState(''); const [mnemonic, setMnemonic] = useState(''); const [mnemonicWords, setMnemonicWords] = useState([]); const [error, setError] = useState(); - const history = useHistory(); - const generateMnemonic = async () => { const mnemonicPhrase = await createMnemonic(); setMnemonic(mnemonicPhrase); }; - useEffect(() => { - history.push('/welcome'); - }, []); - useEffect(() => { if (mnemonic.length > 0) { const mnemonicArray = mnemonicToArray(mnemonic); @@ -54,7 +47,7 @@ export const SignInProvider: React.FC = ({ children }) => { }; return ( - ({ error, @@ -71,6 +64,6 @@ export const SignInProvider: React.FC = ({ children }) => { )} > {children} - + ); }; diff --git a/nym-wallet/src/context/index.tsx b/nym-wallet/src/context/index.tsx new file mode 100644 index 0000000000..0077fb3147 --- /dev/null +++ b/nym-wallet/src/context/index.tsx @@ -0,0 +1,3 @@ +export * from './main'; +export * from './auth'; +export * from './accounts'; diff --git a/nym-wallet/src/context/main.tsx b/nym-wallet/src/context/main.tsx index 526a16dbf0..d860336014 100644 --- a/nym-wallet/src/context/main.tsx +++ b/nym-wallet/src/context/main.tsx @@ -1,8 +1,7 @@ import React, { useMemo, createContext, useEffect, useState } from 'react'; import { useHistory } from 'react-router-dom'; import { useSnackbar } from 'notistack'; -import { TLoginType } from 'src/pages/sign-in/types'; -import { Account, AppEnv, Network, TCurrency, TMixnodeBondDetails } from '../types'; +import { Account, Network, TCurrency, TMixnodeBondDetails, AccountEntry, AppEnv } from '../types'; import { TUseuserBalance, useGetBalance } from '../hooks/useGetBalance'; import { getMixnodeBondDetails, @@ -10,7 +9,9 @@ import { signInWithMnemonic, signInWithPassword, signOut, + switchAccount, getEnv, + listAccounts, } from '../requests'; import { currencyMap } from '../utils'; import { Console } from '../utils/console'; @@ -26,10 +27,13 @@ export const urls = (networkName?: Network) => networkExplorer: `https://${networkName}-explorer.nymtech.net`, }; -type TClientContext = { +type TLoginType = 'mnemonic' | 'password'; + +type TAppContext = { mode: 'light' | 'dark'; appEnv?: AppEnv; clientDetails?: Account; + storedAccounts?: AccountEntry[]; mixnodeDetails?: TMixnodeBondDetails | null; userBalance: TUseuserBalance; showAdmin: boolean; @@ -40,30 +44,34 @@ type TClientContext = { isLoading: boolean; isAdminAddress: boolean; error?: string; + loginType?: TLoginType; setIsLoading: (isLoading: boolean) => void; setError: (value?: string) => void; switchNetwork: (network: Network) => void; getBondDetails: () => Promise; handleShowSettings: () => void; handleShowAdmin: () => void; + logIn: (opts: { type: TLoginType; value: string }) => void; handleShowTerminal: () => void; - logIn: (opts: { type: 'mnemonic' | 'password'; value: string }) => void; signInWithPassword: (password: string) => void; logOut: () => void; + onAccountChange: (accountId: string) => void; }; -export const ClientContext = createContext({} as TClientContext); +export const AppContext = createContext({} as TAppContext); -export const ClientContextProvider = ({ children }: { children: React.ReactNode }) => { - const [appEnv, setAppEnv] = useState(); +export const AppProvider = ({ children }: { children: React.ReactNode }) => { const [clientDetails, setClientDetails] = useState(); + const [storedAccounts, setStoredAccounts] = useState(); const [mixnodeDetails, setMixnodeDetails] = useState(); const [network, setNetwork] = useState(); + const [appEnv, setAppEnv] = useState(); const [currency, setCurrency] = useState(); const [showAdmin, setShowAdmin] = useState(false); const [showSettings, setShowSettings] = useState(false); const [showTerminal, setShowTerminal] = useState(false); const [mode] = useState<'light' | 'dark'>('light'); + const [loginType, setLoginType] = useState<'mnemonic' | 'password'>(); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(); @@ -71,6 +79,15 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode const history = useHistory(); const { enqueueSnackbar } = useSnackbar(); + const clearState = () => { + userBalance.clearAll(); + setStoredAccounts(undefined); + setNetwork(undefined); + setError(undefined); + setIsLoading(false); + setMixnodeDetails(undefined); + }; + const loadAccount = async (n: Network) => { try { const client = await selectNetwork(n); @@ -83,6 +100,11 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode } }; + const loadStoredAccounts = async () => { + const accounts = await listAccounts(); + setStoredAccounts(accounts); + }; + const getBondDetails = async () => { setMixnodeDetails(undefined); try { @@ -93,19 +115,25 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode } }; - useEffect(() => { - getEnv().then(setAppEnv); - }, []); + const refreshAccount = async (_network: Network) => { + await loadAccount(_network); + if (loginType === 'password') { + await loadStoredAccounts(); + } + }; useEffect(() => { - const refreshAccount = async () => { - if (network) { - await loadAccount(network); - await getBondDetails(); - await userBalance.fetchBalance(); - } - }; - refreshAccount(); + if (!clientDetails) { + clearState(); + history.push('/'); + } + }, [clientDetails]); + + useEffect(() => { + if (network) { + refreshAccount(network); + getEnv().then(setAppEnv); + } }, [network]); const logIn = async ({ type, value }: { type: TLoginType; value: string }) => { @@ -117,8 +145,10 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode setIsLoading(true); if (type === 'mnemonic') { await signInWithMnemonic(value); + setLoginType('mnemonic'); } else { await signInWithPassword(value); + setLoginType('password'); } setNetwork('MAINNET'); history.push('/balance'); @@ -130,16 +160,26 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode }; const logOut = async () => { - userBalance.clearAll(); - setClientDetails(undefined); - setNetwork(undefined); - setError(undefined); - setIsLoading(false); - setMixnodeDetails(undefined); await signOut(); + setClientDetails(undefined); enqueueSnackbar('Successfully logged out', { variant: 'success' }); }; + const onAccountChange = async (accountId: string) => { + if (network) { + setIsLoading(true); + try { + await switchAccount(accountId); + await loadAccount(network); + enqueueSnackbar('Account switch success', { variant: 'success', preventDuplicate: true }); + } catch (e) { + enqueueSnackbar(`Error swtiching account: ${e}`, { variant: 'error' }); + } finally { + setIsLoading(false); + } + } + }; + const handleShowAdmin = () => setShowAdmin((show) => !show); const handleShowSettings = () => setShowSettings((show) => !show); const handleShowTerminal = () => setShowTerminal((show) => !show); @@ -153,6 +193,7 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode isLoading, error, clientDetails, + storedAccounts, mixnodeDetails, userBalance, showAdmin, @@ -160,6 +201,7 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode showTerminal, network, currency, + loginType, setIsLoading, setError, signInWithPassword, @@ -170,8 +212,10 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode handleShowTerminal, logIn, logOut, + onAccountChange, }), [ + loginType, mode, appEnv, isLoading, @@ -181,11 +225,12 @@ export const ClientContextProvider = ({ children }: { children: React.ReactNode userBalance, showAdmin, showSettings, - showTerminal, network, currency, + storedAccounts, + showTerminal, ], ); - return {children}; + return {children}; }; diff --git a/nym-wallet/src/context/mocks/accounts.tsx b/nym-wallet/src/context/mocks/accounts.tsx new file mode 100644 index 0000000000..8b85530b63 --- /dev/null +++ b/nym-wallet/src/context/mocks/accounts.tsx @@ -0,0 +1,84 @@ +import React, { useMemo, useState } from 'react'; +import { AccountEntry } from 'src/types'; +import { AccountsContext, TAccountMnemonic, TAccountsDialog } from '../accounts'; + +export const MockAccountsProvider: React.FC = ({ children }) => { + const [accounts, setAccounts] = useState([{ id: 'Account_1', address: 'abc123' }]); + const [selectedAccount, setSelectedAccount] = useState({ + id: 'Account_1', + address: 'abc123', + }); + const [accountToEdit, setAccountToEdit] = useState(); + const [dialogToDisplay, setDialogToDisplay] = useState(); + const [accountMnemonic, setAccountMnemonic] = useState({ + value: undefined, + accountName: undefined, + }); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(false); + + const handleAddAccount = async ({ accountName }: { accountName: string; mnemonic: string; password: string }) => { + setIsLoading(true); + try { + setAccounts((accs) => [...accs, { address: 'abc123', id: accountName }]); + setDialogToDisplay('Accounts'); + } catch (e) { + setError(`Error adding account: ${e}`); + } finally { + setIsLoading(false); + } + }; + const handleEditAccount = (account: AccountEntry) => + setAccounts((accs) => accs?.map((acc) => (acc.address === account.address ? account : acc))); + + const handleImportAccount = (account: AccountEntry) => setAccounts((accs) => [...(accs ? [...accs] : []), account]); + + const handleAccountToEdit = (accountName: string) => + setAccountToEdit(accounts?.find((acc) => acc.id === accountName)); + + const handleSelectAccount = async (accountName: string) => { + if (accountName !== selectedAccount?.id) { + const match = accounts?.find((acc) => acc.id === accountName); + setSelectedAccount(match); + } + }; + + const handleGetAcccountMnemonic = async ({ accountName }: { password: string; accountName: string }) => { + try { + setIsLoading(true); + const mnemonic = 'test mnemonic'; + setAccountMnemonic({ value: mnemonic, accountName }); + } catch (e) { + setError(e as string); + } finally { + setIsLoading(false); + } + }; + return ( + ({ + error, + setError, + accounts, + selectedAccount, + accountToEdit, + dialogToDisplay, + accountMnemonic, + setDialogToDisplay, + setAccountMnemonic, + isLoading, + handleAddAccount, + handleEditAccount, + handleAccountToEdit, + handleSelectAccount, + handleImportAccount, + handleGetAcccountMnemonic, + }), + [accounts, selectedAccount, accountToEdit, dialogToDisplay, isLoading, error, accountMnemonic], + )} + > + {children} + + ); +}; diff --git a/nym-wallet/src/hooks/useCheckOwnership.ts b/nym-wallet/src/hooks/useCheckOwnership.ts index b9cf89ebf4..b47df00efd 100644 --- a/nym-wallet/src/hooks/useCheckOwnership.ts +++ b/nym-wallet/src/hooks/useCheckOwnership.ts @@ -1,6 +1,6 @@ import { useCallback, useContext, useEffect, useState } from 'react'; import { Console } from '../utils/console'; -import { ClientContext } from '../context/main'; +import { AppContext } from '../context/main'; import { checkGatewayOwnership, checkMixnodeOwnership, getVestingPledgeInfo } from '../requests'; import { EnumNodeType, TNodeOwnership } from '../types'; @@ -11,7 +11,7 @@ const initial = { }; export const useCheckOwnership = () => { - const { clientDetails } = useContext(ClientContext); + const { clientDetails } = useContext(AppContext); const [ownership, setOwnership] = useState(initial); const [isLoading, setIsLoading] = useState(true); diff --git a/nym-wallet/src/hooks/useGetBalance.ts b/nym-wallet/src/hooks/useGetBalance.ts index 7c43c433f1..9056f1d4d2 100644 --- a/nym-wallet/src/hooks/useGetBalance.ts +++ b/nym-wallet/src/hooks/useGetBalance.ts @@ -92,9 +92,7 @@ export const useGetBalance = (address?: string): TUseuserBalance => { } catch (err) { setError(err as string); } finally { - setTimeout(() => { - setIsLoading(false); - }, 1000); + setIsLoading(false); } }, []); diff --git a/nym-wallet/src/index.tsx b/nym-wallet/src/index.tsx index 78b7190634..76d20f0ec0 100644 --- a/nym-wallet/src/index.tsx +++ b/nym-wallet/src/index.tsx @@ -1,60 +1,39 @@ -import React, { useContext, useEffect } from 'react'; +import React, { useEffect } from 'react'; import ReactDOM from 'react-dom'; import { ErrorBoundary } from 'react-error-boundary'; import { BrowserRouter as Router } from 'react-router-dom'; import { SnackbarProvider } from 'notistack'; -import { AppRoutes, SignInRoutes } from './routes'; -import { ClientContext, ClientContextProvider } from './context/main'; -import { ApplicationLayout } from './layouts'; -import { Admin, Settings } from './pages'; +import { Routes } from './routes'; +import { AppProvider } from './context/main'; import { ErrorFallback } from './components'; -import { NymWalletTheme, WelcomeTheme } from './theme'; +import { NymWalletTheme } from './theme'; import { maximizeWindow } from './utils'; -import { SignInProvider } from './pages/sign-in/context'; -import { Terminal } from './pages/terminal'; const App = () => { - const { clientDetails } = useContext(ClientContext); - useEffect(() => { maximizeWindow(); }, []); - return !clientDetails ? ( - - - - - - ) : ( - - - - - - - - + return ( + + + + + + + + + + + ); }; -const AppWrapper = () => ( - - - - - - - - - -); - const root = document.getElementById('root'); -ReactDOM.render(, root); +ReactDOM.render(, root); diff --git a/nym-wallet/src/layouts/AppLayout.tsx b/nym-wallet/src/layouts/AppLayout.tsx index 521c3de940..c583b3be5e 100644 --- a/nym-wallet/src/layouts/AppLayout.tsx +++ b/nym-wallet/src/layouts/AppLayout.tsx @@ -1,44 +1,52 @@ -import React from 'react'; +import React, { useContext } from 'react'; import { NymWordmark } from '@nymproject/react'; import { Box, Container } from '@mui/material'; import { useTheme } from '@mui/material/styles'; -import { AppBar, Nav } from '../components'; +import { AppContext } from 'src/context'; +import { Settings } from 'src/pages'; +import { AppBar, LoadingPage, Nav } from '../components'; export const ApplicationLayout: React.FC = ({ children }) => { const theme = useTheme(); + const { isLoading, showSettings } = useContext(AppContext); + return ( - + <> + {isLoading && } + {showSettings && } - - - + + + + + +