diff --git a/docs/DEV_ENVIRONMENT.md b/docs/DEV_ENVIRONMENT.md index 5e4f6d1659..8782769079 100644 --- a/docs/DEV_ENVIRONMENT.md +++ b/docs/DEV_ENVIRONMENT.md @@ -66,16 +66,34 @@ CC=/opt/homebrew/opt/llvm/bin/clang AR=/opt/homebrew/opt/llvm/bin/llvm-ar wasm-pack test --firefox --headless mm2src/mm2_main ``` Please note `CC` and `AR` must be specified in the same line as `wasm-pack test mm2src/mm2_main`. -#### Running specific WASM tests with Cargo
- - Install `wasm-bindgen-cli`:
- Make sure you have wasm-bindgen-cli installed with a version that matches the one specified in your Cargo.toml file. - You can install it using Cargo with the following command: - ``` - cargo install -f wasm-bindgen-cli --version - ``` - - Run - ``` - cargo test --target wasm32-unknown-unknown --package coins --lib utxo::utxo_block_header_storage::wasm::indexeddb_block_header_storage - ``` + +#### Running specific WASM tests + +There are two primary methods for running specific tests: + +* **Method 1: Using `wasm-pack` (Recommended for browser-based tests)** + + To filter tests, append `--` to the `wasm-pack test` command, followed by the name of the test you want to run. This will execute only the tests whose names contain the provided string. + + General Example: + ```shell + wasm-pack test --firefox --headless mm2src/mm2_main -- + ``` + + > **Note for macOS users:** You must prepend the `CC` and `AR` environment variables to the command if they weren't already exported, just as you would when running all tests. For example: `CC=... AR=... wasm-pack test ...` + +* **Method 2: Using `cargo test` (For non-browser tests)** + + This method uses the standard Cargo test runner with a wasm target and is useful for tests that do not require a browser environment. + + a. **Install `wasm-bindgen-cli`**: Make sure you have `wasm-bindgen-cli` installed with a version that matches the one specified in your `Cargo.toml` file. + ```shell + cargo install -f wasm-bindgen-cli --version + ``` + + b. **Run the test**: Append `--` to the `cargo test` command, followed by the test path. + ```shell + cargo test --target wasm32-unknown-unknown --package coins --lib -- utxo::utxo_block_header_storage::wasm::indexeddb_block_header_storage + ``` PS If you notice that this guide is outdated, please submit a PR. diff --git a/mm2src/coins/qrc20/qrc20_tests.rs b/mm2src/coins/qrc20/qrc20_tests.rs index 01cf4adcad..f4dd5a7121 100644 --- a/mm2src/coins/qrc20/qrc20_tests.rs +++ b/mm2src/coins/qrc20/qrc20_tests.rs @@ -429,6 +429,7 @@ fn test_validate_fee() { } #[test] +#[ignore] fn test_wait_for_tx_spend_malicious() { // priv_key of qXxsj5RtciAby9T7m98AgAATL4zTi4UwDG let priv_key = [ diff --git a/mm2src/mm2_main/src/lp_native_dex.rs b/mm2src/mm2_main/src/lp_native_dex.rs index a24a75d8ce..4877ef8de8 100644 --- a/mm2src/mm2_main/src/lp_native_dex.rs +++ b/mm2src/mm2_main/src/lp_native_dex.rs @@ -113,7 +113,6 @@ impl From for P2PInitError { } } } - #[derive(Clone, Debug, Display, EnumFromTrait, Serialize, SerializeErrorType)] #[serde(tag = "error_type", content = "error_data")] pub enum MmInitError { @@ -534,6 +533,10 @@ fn p2p_precheck(ctx: &MmArc) -> P2PResult<()> { } } + if is_seed_node && !CryptoCtx::is_init(ctx).unwrap_or(false) { + return precheck_err("Seed node requires a persistent identity to generate its P2P key."); + } + Ok(()) } diff --git a/mm2src/mm2_main/src/lp_wallet.rs b/mm2src/mm2_main/src/lp_wallet.rs index 8bb64690e2..7743b05039 100644 --- a/mm2src/mm2_main/src/lp_wallet.rs +++ b/mm2src/mm2_main/src/lp_wallet.rs @@ -14,14 +14,14 @@ cfg_wasm32! { use crate::lp_wallet::mnemonics_wasm_db::{WalletsDb, WalletsDBError}; use mm2_core::mm_ctx::from_ctx; use mm2_db::indexed_db::{ConstructibleDb, DbLocked, InitDbResult}; - use mnemonics_wasm_db::{read_all_wallet_names, read_encrypted_passphrase_if_available, save_encrypted_passphrase}; + use mnemonics_wasm_db::{delete_wallet, read_all_wallet_names, read_encrypted_passphrase, save_encrypted_passphrase}; use std::sync::Arc; type WalletsDbLocked<'a> = DbLocked<'a, WalletsDb>; } cfg_native! { - use mnemonics_storage::{read_all_wallet_names, read_encrypted_passphrase_if_available, save_encrypted_passphrase, WalletsStorageError}; + use mnemonics_storage::{delete_wallet, read_all_wallet_names, read_encrypted_passphrase, save_encrypted_passphrase, WalletsStorageError}; } #[cfg(not(target_arch = "wasm32"))] mod mnemonics_storage; #[cfg(target_arch = "wasm32")] mod mnemonics_wasm_db; @@ -69,6 +69,8 @@ pub enum ReadPassphraseError { WalletsStorageError(String), #[display(fmt = "Error decrypting passphrase: {}", _0)] DecryptionError(String), + #[display(fmt = "Internal error: {}", _0)] + Internal(String), } impl From for WalletInitError { @@ -76,6 +78,7 @@ impl From for WalletInitError { match e { ReadPassphraseError::WalletsStorageError(e) => WalletInitError::WalletsStorageError(e), ReadPassphraseError::DecryptionError(e) => WalletInitError::MnemonicError(e), + ReadPassphraseError::Internal(e) => WalletInitError::InternalError(e), } } } @@ -121,25 +124,39 @@ async fn encrypt_and_save_passphrase( .mm_err(|e| WalletInitError::WalletsStorageError(e.to_string())) } -/// Reads and decrypts the passphrase from a file associated with the given wallet name, if available. -/// -/// This function first checks if a passphrase is available. If a passphrase is found, -/// since it is stored in an encrypted format, it decrypts it before returning. If no passphrase is found, -/// it returns `None`. -/// -/// # Returns -/// `MmInitResult` - The decrypted passphrase or an error if any operation fails. +/// A convenience wrapper that calls [`try_load_wallet_passphrase`] for the currently active wallet. +async fn try_load_active_wallet_passphrase( + ctx: &MmArc, + wallet_password: &str, +) -> MmResult, ReadPassphraseError> { + let wallet_name = ctx + .wallet_name + .get() + .ok_or(ReadPassphraseError::Internal( + "`wallet_name` not initialized yet!".to_string(), + ))? + .clone() + .ok_or_else(|| { + ReadPassphraseError::Internal("Cannot read stored passphrase: no active wallet is set.".to_string()) + })?; + + try_load_wallet_passphrase(ctx, &wallet_name, wallet_password).await +} + +/// Loads (reads from storage and decrypts) a passphrase for a specific wallet by name. /// -/// # Errors -/// Returns specific `MmInitError` variants for different failure scenarios. -async fn read_and_decrypt_passphrase_if_available( +/// Returns `Ok(None)` if the passphrase is not found in storage. This is an expected +/// outcome for a new wallet or when using a legacy config where the passphrase is not saved. +async fn try_load_wallet_passphrase( ctx: &MmArc, + wallet_name: &str, wallet_password: &str, ) -> MmResult, ReadPassphraseError> { - match read_encrypted_passphrase_if_available(ctx) + let encrypted = read_encrypted_passphrase(ctx, wallet_name) .await - .mm_err(|e| ReadPassphraseError::WalletsStorageError(e.to_string()))? - { + .mm_err(|e| ReadPassphraseError::WalletsStorageError(e.to_string()))?; + + match encrypted { Some(encrypted_passphrase) => { let mnemonic = decrypt_mnemonic(&encrypted_passphrase, wallet_password) .mm_err(|e| ReadPassphraseError::DecryptionError(e.to_string()))?; @@ -171,7 +188,7 @@ async fn retrieve_or_create_passphrase( wallet_name: &str, wallet_password: &str, ) -> WalletInitResult> { - match read_and_decrypt_passphrase_if_available(ctx, wallet_password).await? { + match try_load_active_wallet_passphrase(ctx, wallet_password).await? { Some(passphrase_from_file) => { // If an existing passphrase is found, return it Ok(Some(passphrase_from_file)) @@ -202,7 +219,7 @@ async fn confirm_or_encrypt_and_store_passphrase( passphrase: &str, wallet_password: &str, ) -> WalletInitResult> { - match read_and_decrypt_passphrase_if_available(ctx, wallet_password).await? { + match try_load_active_wallet_passphrase(ctx, wallet_password).await? { Some(passphrase_from_file) if passphrase == passphrase_from_file => { // If an existing passphrase is found and it matches the provided passphrase, return it Ok(Some(passphrase_from_file)) @@ -238,7 +255,7 @@ async fn decrypt_validate_or_save_passphrase( // Decrypt the provided encrypted passphrase let decrypted_passphrase = decrypt_mnemonic(&encrypted_passphrase_data, wallet_password)?; - match read_and_decrypt_passphrase_if_available(ctx, wallet_password).await? { + match try_load_active_wallet_passphrase(ctx, wallet_password).await? { Some(passphrase_from_file) if decrypted_passphrase == passphrase_from_file => { // If an existing passphrase is found and it matches the decrypted passphrase, return it Ok(Some(decrypted_passphrase)) @@ -476,7 +493,13 @@ impl From for MnemonicRpcError { } impl From for MnemonicRpcError { - fn from(e: ReadPassphraseError) -> Self { MnemonicRpcError::WalletsStorageError(e.to_string()) } + fn from(e: ReadPassphraseError) -> Self { + match e { + ReadPassphraseError::DecryptionError(e) => MnemonicRpcError::InvalidPassword(e), + ReadPassphraseError::WalletsStorageError(e) => MnemonicRpcError::WalletsStorageError(e), + ReadPassphraseError::Internal(e) => MnemonicRpcError::Internal(e), + } + } } /// Retrieves the wallet mnemonic in the requested format. @@ -513,7 +536,19 @@ impl From for MnemonicRpcError { pub async fn get_mnemonic_rpc(ctx: MmArc, req: GetMnemonicRequest) -> MmResult { match req.mnemonic_format { MnemonicFormat::Encrypted => { - let encrypted_mnemonic = read_encrypted_passphrase_if_available(&ctx) + let wallet_name = ctx + .wallet_name + .get() + .ok_or(MnemonicRpcError::Internal( + "`wallet_name` not initialized yet!".to_string(), + ))? + .as_ref() + .ok_or_else(|| { + MnemonicRpcError::Internal( + "Cannot get encrypted mnemonic: This operation requires an active named wallet.".to_string(), + ) + })?; + let encrypted_mnemonic = read_encrypted_passphrase(&ctx, wallet_name) .await? .ok_or_else(|| MnemonicRpcError::InvalidRequest("Wallet mnemonic file not found".to_string()))?; Ok(GetMnemonicResponse { @@ -521,7 +556,7 @@ pub async fn get_mnemonic_rpc(ctx: MmArc, req: GetMnemonicRequest) -> MmResult { - let plaintext_mnemonic = read_and_decrypt_passphrase_if_available(&ctx, &wallet_password) + let plaintext_mnemonic = try_load_active_wallet_passphrase(&ctx, &wallet_password) .await? .ok_or_else(|| MnemonicRpcError::InvalidRequest("Wallet mnemonic file not found".to_string()))?; Ok(GetMnemonicResponse { @@ -584,7 +619,7 @@ pub async fn change_mnemonic_password(ctx: MmArc, req: ChangeMnemonicPasswordReq .as_ref() .ok_or_else(|| MnemonicRpcError::Internal("`wallet_name` cannot be None!".to_string()))?; // read mnemonic for a wallet_name using current user's password. - let mnemonic = read_and_decrypt_passphrase_if_available(&ctx, &req.current_password) + let mnemonic = try_load_active_wallet_passphrase(&ctx, &req.current_password) .await? .ok_or(MmError::new(MnemonicRpcError::Internal(format!( "{wallet_name}: wallet mnemonic file not found" @@ -596,3 +631,48 @@ pub async fn change_mnemonic_password(ctx: MmArc, req: ChangeMnemonicPasswordReq Ok(()) } + +#[derive(Debug, Deserialize)] +pub struct DeleteWalletRequest { + /// The name of the wallet to be deleted. + pub wallet_name: String, + /// The password to confirm wallet deletion. + pub password: String, +} + +/// Deletes a wallet. Requires password confirmation. +/// The active wallet cannot be deleted. +pub async fn delete_wallet_rpc(ctx: MmArc, req: DeleteWalletRequest) -> MmResult<(), MnemonicRpcError> { + let active_wallet = ctx + .wallet_name + .get() + .ok_or(MnemonicRpcError::Internal( + "`wallet_name` not initialized yet!".to_string(), + ))? + .as_ref(); + + if active_wallet == Some(&req.wallet_name) { + return MmError::err(MnemonicRpcError::InvalidRequest(format!( + "Cannot delete wallet '{}' as it is currently active.", + req.wallet_name + ))); + } + + // Verify the password by attempting to decrypt the mnemonic. + let maybe_mnemonic = try_load_wallet_passphrase(&ctx, &req.wallet_name, &req.password).await?; + + match maybe_mnemonic { + Some(_) => { + // Password is correct, proceed with deletion. + delete_wallet(&ctx, &req.wallet_name).await?; + Ok(()) + }, + None => { + // This case implies no mnemonic file was found for the given wallet. + MmError::err(MnemonicRpcError::InvalidRequest(format!( + "Wallet '{}' not found.", + req.wallet_name + ))) + }, + } +} diff --git a/mm2src/mm2_main/src/lp_wallet/mnemonics_storage.rs b/mm2src/mm2_main/src/lp_wallet/mnemonics_storage.rs index 25e77c27d1..f2636be4a0 100644 --- a/mm2src/mm2_main/src/lp_wallet/mnemonics_storage.rs +++ b/mm2src/mm2_main/src/lp_wallet/mnemonics_storage.rs @@ -57,27 +57,22 @@ pub(super) async fn save_encrypted_passphrase( /// Reads the encrypted passphrase data from the file associated with the given wallet name, if available. /// -/// This function is responsible for retrieving the encrypted passphrase data from a file, if it exists. +/// This function is responsible for retrieving the encrypted passphrase data from a file for a specific wallet. /// The data is expected to be in the format of `EncryptedData`, which includes /// all necessary components for decryption, such as the encryption algorithm, key derivation /// /// # Returns -/// `io::Result` - The encrypted passphrase data or an error if the -/// reading process fails. +/// `WalletsStorageResult>` - The encrypted passphrase data or an error if the +/// reading process fails. An `Ok(None)` is returned if the wallet file does not exist. /// /// # Errors -/// Returns an `io::Error` if the file cannot be read or the data cannot be deserialized into +/// Returns a `WalletsStorageError` if the file cannot be read or the data cannot be deserialized into /// `EncryptedData`. -pub(super) async fn read_encrypted_passphrase_if_available(ctx: &MmArc) -> WalletsStorageResult> { - let wallet_name = ctx - .wallet_name - .get() - .ok_or(WalletsStorageError::Internal( - "`wallet_name` not initialized yet!".to_string(), - ))? - .clone() - .ok_or_else(|| WalletsStorageError::Internal("`wallet_name` cannot be None!".to_string()))?; - let wallet_path = wallet_file_path(ctx, &wallet_name).map_to_mm(WalletsStorageError::InvalidWalletName)?; +pub(super) async fn read_encrypted_passphrase( + ctx: &MmArc, + wallet_name: &str, +) -> WalletsStorageResult> { + let wallet_path = wallet_file_path(ctx, wallet_name).map_to_mm(WalletsStorageError::InvalidWalletName)?; mm2_io::fs::read_json(&wallet_path).await.mm_err(|e| { WalletsStorageError::FsReadError(format!( "Error reading passphrase from file {}: {}", @@ -93,3 +88,11 @@ pub(super) async fn read_all_wallet_names(ctx: &MmArc) -> WalletsStorageResult WalletsStorageResult<()> { + let wallet_path = wallet_file_path(ctx, wallet_name).map_to_mm(WalletsStorageError::InvalidWalletName)?; + mm2_io::fs::remove_file_async(&wallet_path) + .await + .mm_err(|e| WalletsStorageError::FsWriteError(e.to_string())) +} diff --git a/mm2src/mm2_main/src/lp_wallet/mnemonics_wasm_db.rs b/mm2src/mm2_main/src/lp_wallet/mnemonics_wasm_db.rs index e4733a132d..6eeaebc8d4 100644 --- a/mm2src/mm2_main/src/lp_wallet/mnemonics_wasm_db.rs +++ b/mm2src/mm2_main/src/lp_wallet/mnemonics_wasm_db.rs @@ -119,21 +119,16 @@ pub(super) async fn save_encrypted_passphrase( Ok(()) } -pub(super) async fn read_encrypted_passphrase_if_available(ctx: &MmArc) -> WalletsDBResult> { +pub(super) async fn read_encrypted_passphrase( + ctx: &MmArc, + wallet_name: &str, +) -> WalletsDBResult> { let wallets_ctx = WalletsContext::from_ctx(ctx).map_to_mm(WalletsDBError::Internal)?; let db = wallets_ctx.wallets_db().await?; let transaction = db.transaction().await?; let table = transaction.table::().await?; - let wallet_name = ctx - .wallet_name - .get() - .ok_or(WalletsDBError::Internal( - "`wallet_name` not initialized yet!".to_string(), - ))? - .clone() - .ok_or_else(|| WalletsDBError::Internal("`wallet_name` can't be None!".to_string()))?; table .get_item_by_unique_index("wallet_name", wallet_name) .await? @@ -160,3 +155,14 @@ pub(super) async fn read_all_wallet_names(ctx: &MmArc) -> WalletsDBResult WalletsDBResult<()> { + let wallets_ctx = WalletsContext::from_ctx(ctx).map_to_mm(WalletsDBError::Internal)?; + + let db = wallets_ctx.wallets_db().await?; + let transaction = db.transaction().await?; + let table = transaction.table::().await?; + + table.delete_item_by_unique_index("wallet_name", wallet_name).await?; + Ok(()) +} diff --git a/mm2src/mm2_main/src/rpc/dispatcher/dispatcher.rs b/mm2src/mm2_main/src/rpc/dispatcher/dispatcher.rs index 924b87f387..4b18118a7d 100644 --- a/mm2src/mm2_main/src/rpc/dispatcher/dispatcher.rs +++ b/mm2src/mm2_main/src/rpc/dispatcher/dispatcher.rs @@ -11,7 +11,7 @@ use crate::lp_stats::{add_node_to_version_stat, remove_node_from_version_stat, s stop_version_stat_collection, update_version_stat_collection}; use crate::lp_swap::swap_v2_rpcs::{active_swaps_rpc, my_recent_swaps_rpc, my_swap_status_rpc}; use crate::lp_swap::{get_locked_amount_rpc, max_maker_vol, recreate_swap_data, trade_preimage_rpc}; -use crate::lp_wallet::{change_mnemonic_password, get_mnemonic_rpc, get_wallet_names_rpc}; +use crate::lp_wallet::{change_mnemonic_password, delete_wallet_rpc, get_mnemonic_rpc, get_wallet_names_rpc}; use crate::rpc::lp_commands::db_id::get_shared_db_id; use crate::rpc::lp_commands::one_inch::rpcs::{one_inch_v6_0_classic_swap_contract_rpc, one_inch_v6_0_classic_swap_create_rpc, @@ -201,6 +201,7 @@ async fn dispatcher_v2(request: MmRpcRequest, ctx: MmArc) -> DispatcherResult handle_mmrpc(ctx, request, get_token_allowance_rpc).await, "best_orders" => handle_mmrpc(ctx, request, best_orders_rpc_v2).await, "clear_nft_db" => handle_mmrpc(ctx, request, clear_nft_db).await, + "delete_wallet" => handle_mmrpc(ctx, request, delete_wallet_rpc).await, "enable_bch_with_tokens" => handle_mmrpc(ctx, request, enable_platform_coin_with_tokens::).await, "enable_slp" => handle_mmrpc(ctx, request, enable_token::).await, "enable_eth_with_tokens" => handle_mmrpc(ctx, request, enable_platform_coin_with_tokens::).await, diff --git a/mm2src/mm2_main/src/wasm_tests.rs b/mm2src/mm2_main/src/wasm_tests.rs index 11d31fb91c..a8768b675b 100644 --- a/mm2src/mm2_main/src/wasm_tests.rs +++ b/mm2src/mm2_main/src/wasm_tests.rs @@ -2,18 +2,19 @@ use crate::{lp_init, lp_run}; use common::executor::{spawn, spawn_abortable, spawn_local_abortable, AbortOnDropHandle, Timer}; use common::log::warn; use common::log::wasm_log::register_wasm_log; +use http::StatusCode; use mm2_core::mm_ctx::MmArc; use mm2_number::BigDecimal; use mm2_rpc::data::legacy::OrderbookResponse; use mm2_test_helpers::electrums::{doc_electrums, marty_electrums}; -use mm2_test_helpers::for_tests::{check_recent_swaps, enable_electrum_json, enable_utxo_v2_electrum, +use mm2_test_helpers::for_tests::{check_recent_swaps, delete_wallet, enable_electrum_json, enable_utxo_v2_electrum, enable_z_coin_light, get_wallet_names, morty_conf, pirate_conf, rick_conf, start_swaps, test_qrc20_history_impl, wait_for_swaps_finish_and_check_status, MarketMakerIt, Mm2InitPrivKeyPolicy, Mm2TestConf, Mm2TestConfForSwap, ARRR, MORTY, PIRATE_ELECTRUMS, PIRATE_LIGHTWALLETD_URLS, RICK}; use mm2_test_helpers::get_passphrase; use mm2_test_helpers::structs::{Bip44Chain, EnableCoinBalance, HDAccountAddressId}; -use serde_json::json; +use serde_json::{json, Value as Json}; use wasm_bindgen_test::wasm_bindgen_test; const PIRATE_TEST_BALANCE_SEED: &str = "pirate test seed"; @@ -80,9 +81,6 @@ async fn test_mm2_stops_immediately() { test_mm2_stops_impl(pairs, 1., 1., 0.0001).await; } -#[wasm_bindgen_test] -async fn test_qrc20_tx_history() { test_qrc20_history_impl(Some(wasm_start)).await } - async fn trade_base_rel_electrum( mut mm_bob: MarketMakerIt, mut mm_alice: MarketMakerIt, @@ -303,3 +301,95 @@ async fn test_get_wallet_names() { .await .unwrap(); } + +#[wasm_bindgen_test] +async fn test_delete_wallet_rpc() { + register_wasm_log(); + + const DB_NAMESPACE_NUM: u64 = 2; + + let coins = json!([]); + let wallet_1_name = "wallet_to_be_deleted"; + let wallet_1_pass = "pass1"; + let wallet_1_conf = Mm2TestConf::seednode_with_wallet_name(&coins, wallet_1_name, wallet_1_pass); + let mm_wallet_1 = MarketMakerIt::start_with_db( + wallet_1_conf.conf, + wallet_1_conf.rpc_password, + Some(wasm_start), + DB_NAMESPACE_NUM, + ) + .await + .unwrap(); + + let get_wallet_names_1 = get_wallet_names(&mm_wallet_1).await; + assert_eq!(get_wallet_names_1.wallet_names, vec![wallet_1_name]); + assert_eq!(get_wallet_names_1.activated_wallet.as_deref(), Some(wallet_1_name)); + + mm_wallet_1 + .stop_and_wait_for_ctx_is_dropped(STOP_TIMEOUT_MS) + .await + .unwrap(); + + let wallet_2_name = "active_wallet"; + let wallet_2_pass = "pass2"; + let wallet_2_conf = Mm2TestConf::seednode_with_wallet_name(&coins, wallet_2_name, wallet_2_pass); + let mm_wallet_2 = MarketMakerIt::start_with_db( + wallet_2_conf.conf, + wallet_2_conf.rpc_password, + Some(wasm_start), + DB_NAMESPACE_NUM, + ) + .await + .unwrap(); + + let wallet_names = get_wallet_names(&mm_wallet_2).await.wallet_names; + assert_eq!(wallet_names, vec![wallet_2_name, wallet_1_name]); + let activated_wallet = get_wallet_names(&mm_wallet_2).await.activated_wallet; + assert_eq!(activated_wallet.as_deref(), Some(wallet_2_name)); + + // Try to delete the active wallet - should fail + let (_, body, _) = delete_wallet(&mm_wallet_2, wallet_2_name, wallet_2_pass).await; + let error: Json = serde_json::from_str(&body).unwrap(); + assert_eq!(error["error_type"], "InvalidRequest"); + assert!(error["error_data"] + .as_str() + .unwrap() + .contains("Cannot delete wallet 'active_wallet' as it is currently active.")); + + // Try to delete with the wrong password - should fail + let (_, body, _) = delete_wallet(&mm_wallet_2, wallet_1_name, "wrong_pass").await; + let error: Json = serde_json::from_str(&body).unwrap(); + assert_eq!(error["error_type"], "InvalidPassword"); + assert!(error["error_data"] + .as_str() + .unwrap() + .contains("Error decrypting mnemonic")); + + // Try to delete a non-existent wallet - should fail + let (_, body, _) = delete_wallet(&mm_wallet_2, "non_existent_wallet", "any_pass").await; + let error: Json = serde_json::from_str(&body).unwrap(); + assert_eq!(error["error_type"], "InvalidRequest"); + assert!(error["error_data"] + .as_str() + .unwrap() + .contains("Wallet 'non_existent_wallet' not found.")); + + // Delete the inactive wallet with the correct password - should succeed + let (_, body, _) = delete_wallet(&mm_wallet_2, wallet_1_name, wallet_1_pass).await; + let response: Json = serde_json::from_str(&body).expect("Response should be valid JSON"); + assert!( + response["result"].is_null(), + "Expected a successful response with null result, but got error: {}", + body + ); + + // Verify the wallet is deleted + let get_wallet_names_3 = get_wallet_names(&mm_wallet_2).await; + assert_eq!(get_wallet_names_3.wallet_names, vec![wallet_2_name]); + assert_eq!(get_wallet_names_3.activated_wallet.as_deref(), Some(wallet_2_name)); + + mm_wallet_2 + .stop_and_wait_for_ctx_is_dropped(STOP_TIMEOUT_MS) + .await + .unwrap(); +} diff --git a/mm2src/mm2_main/tests/mm2_tests/mm2_tests_inner.rs b/mm2src/mm2_main/tests/mm2_tests/mm2_tests_inner.rs index 117fff3558..392a046838 100644 --- a/mm2src/mm2_main/tests/mm2_tests/mm2_tests_inner.rs +++ b/mm2src/mm2_main/tests/mm2_tests/mm2_tests_inner.rs @@ -13,10 +13,10 @@ use mm2_test_helpers::electrums::*; #[cfg(all(not(target_arch = "wasm32"), not(feature = "zhtlc-native-tests")))] use mm2_test_helpers::for_tests::wait_check_stats_swap_status; use mm2_test_helpers::for_tests::{account_balance, btc_segwit_conf, btc_with_spv_conf, btc_with_sync_starting_header, - check_recent_swaps, enable_qrc20, enable_utxo_v2_electrum, eth_dev_conf, - find_metrics_in_json, from_env_file, get_new_address, get_shared_db_id, - get_wallet_names, mm_spat, morty_conf, my_balance, rick_conf, sign_message, - start_swaps, tbtc_conf, tbtc_segwit_conf, tbtc_with_spv_conf, + check_recent_swaps, delete_wallet, enable_qrc20, enable_utxo_v2_electrum, + eth_dev_conf, find_metrics_in_json, from_env_file, get_new_address, + get_shared_db_id, get_wallet_names, mm_spat, morty_conf, my_balance, rick_conf, + sign_message, start_swaps, tbtc_conf, tbtc_segwit_conf, tbtc_with_spv_conf, test_qrc20_history_impl, tqrc20_conf, verify_message, wait_for_swaps_finish_and_check_status, wait_till_history_has_records, MarketMakerIt, Mm2InitPrivKeyPolicy, Mm2TestConf, Mm2TestConfForSwap, RaiiDump, @@ -3751,6 +3751,7 @@ fn test_get_raw_transaction() { } #[test] +#[ignore] #[cfg(not(target_arch = "wasm32"))] fn test_qrc20_tx_history() { block_on(test_qrc20_history_impl(None)); } @@ -6357,7 +6358,7 @@ fn test_change_mnemonic_password_rpc() { .unwrap(); assert_eq!( request.0, - StatusCode::INTERNAL_SERVER_ERROR, + StatusCode::BAD_REQUEST, "'change_mnemonic_password' failed: {}", request.1 ); @@ -6381,6 +6382,119 @@ fn test_change_mnemonic_password_rpc() { ); } +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn test_delete_wallet_rpc() { + let coins = json!([]); + let wallet_1_name = "wallet_to_be_deleted"; + let wallet_1_pass = "pass1"; + let wallet_1_conf = Mm2TestConf::seednode_with_wallet_name(&coins, wallet_1_name, wallet_1_pass); + let mm_wallet_1 = MarketMakerIt::start(wallet_1_conf.conf, wallet_1_conf.rpc_password, None).unwrap(); + + let get_wallet_names_1 = block_on(get_wallet_names(&mm_wallet_1)); + assert_eq!(get_wallet_names_1.wallet_names, vec![wallet_1_name]); + assert_eq!(get_wallet_names_1.activated_wallet.as_deref(), Some(wallet_1_name)); + + let wallet_2_name = "active_wallet"; + let wallet_2_pass = "pass2"; + let mut wallet_2_conf = Mm2TestConf::seednode_with_wallet_name(&coins, wallet_2_name, wallet_2_pass); + wallet_2_conf.conf["dbdir"] = mm_wallet_1.folder.join("DB").to_str().unwrap().into(); + + block_on(mm_wallet_1.stop()).unwrap(); + + let mm_wallet_2 = MarketMakerIt::start(wallet_2_conf.conf, wallet_2_conf.rpc_password, None).unwrap(); + + let get_wallet_names_2 = block_on(get_wallet_names(&mm_wallet_2)); + assert_eq!(get_wallet_names_2.wallet_names, vec![ + "active_wallet", + "wallet_to_be_deleted" + ]); + assert_eq!(get_wallet_names_2.activated_wallet.as_deref(), Some(wallet_2_name)); + + // Try to delete the active wallet - should fail + let (status, body, _) = block_on(delete_wallet(&mm_wallet_2, wallet_2_name, wallet_2_pass)); + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(body.contains("Cannot delete wallet 'active_wallet' as it is currently active.")); + + // Try to delete with the wrong password - should fail + let (status, body, _) = block_on(delete_wallet(&mm_wallet_2, wallet_1_name, "wrong_pass")); + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(body.contains("Invalid password")); + + // Try to delete a non-existent wallet - should fail + let (status, body, _) = block_on(delete_wallet(&mm_wallet_2, "non_existent_wallet", "any_pass")); + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(body.contains("Wallet 'non_existent_wallet' not found.")); + + // Delete the inactive wallet with the correct password - should succeed + let (status, body, _) = block_on(delete_wallet(&mm_wallet_2, wallet_1_name, wallet_1_pass)); + assert_eq!(status, StatusCode::OK, "Body: {}", body); + + // Verify the wallet is deleted + let get_wallet_names_3 = block_on(get_wallet_names(&mm_wallet_2)); + assert_eq!(get_wallet_names_3.wallet_names, vec![wallet_2_name]); + assert_eq!(get_wallet_names_3.activated_wallet.as_deref(), Some(wallet_2_name)); + + block_on(mm_wallet_2.stop()).unwrap(); +} + +#[test] +#[cfg(not(target_arch = "wasm32"))] +fn test_delete_wallet_in_no_login_mode() { + // 0. Setup a seednode to be able to run a no-login node. + let seednode_conf = Mm2TestConf::seednode_with_wallet_name(&json!([]), "seednode_wallet", "seednode_pass"); + let mm_seednode = MarketMakerIt::start(seednode_conf.conf, seednode_conf.rpc_password, None).unwrap(); + let seednode_ip = mm_seednode.ip.to_string(); + + // 1. Setup: Create a wallet to be deleted later. + let wallet_to_delete_name = "wallet_for_no_login_test"; + let wallet_to_delete_pass = "password123"; + let coins = json!([]); + + let wallet_conf = Mm2TestConf::seednode_with_wallet_name(&coins, wallet_to_delete_name, wallet_to_delete_pass); + let mm_setup = MarketMakerIt::start(wallet_conf.conf.clone(), wallet_conf.rpc_password, None).unwrap(); + + let wallet_names_before = block_on(get_wallet_names(&mm_setup)); + assert_eq!(wallet_names_before.wallet_names, vec![wallet_to_delete_name]); + let db_dir = mm_setup.folder.join("DB"); + block_on(mm_setup.stop()).unwrap(); + + // 2. Execution: Start in no-login mode, connecting to the seednode. + let mut no_login_conf = Mm2TestConf::no_login_node(&coins, &[&seednode_ip]); + no_login_conf.conf["dbdir"] = db_dir.to_str().unwrap().into(); + + let mm_no_login = MarketMakerIt::start(no_login_conf.conf, no_login_conf.rpc_password, None).unwrap(); + + let wallet_names_no_login = block_on(get_wallet_names(&mm_no_login)); + assert!(wallet_names_no_login + .wallet_names + .contains(&wallet_to_delete_name.to_string())); + + let (status, body, _) = block_on(delete_wallet( + &mm_no_login, + wallet_to_delete_name, + wallet_to_delete_pass, + )); + assert_eq!(status, StatusCode::OK, "Delete failed with body: {}", body); + + block_on(mm_no_login.stop()).unwrap(); + + // 3. Verification: Start another instance to check if the wallet is gone. + let mut verification_conf = Mm2TestConf::seednode_with_wallet_name(&coins, "verification_wallet", "pass"); + verification_conf.conf["dbdir"] = db_dir.to_str().unwrap().into(); + let mm_verify = MarketMakerIt::start(verification_conf.conf, verification_conf.rpc_password, None).unwrap(); + + let wallet_names_after = block_on(get_wallet_names(&mm_verify)); + assert!(!wallet_names_after + .wallet_names + .contains(&wallet_to_delete_name.to_string())); + + block_on(mm_verify.stop()).unwrap(); + + // 4. Teardown: Stop the seednode. + block_on(mm_seednode.stop()).unwrap(); +} + #[test] #[cfg(not(target_arch = "wasm32"))] fn test_sign_raw_transaction_rick() { diff --git a/mm2src/mm2_test_helpers/src/for_tests.rs b/mm2src/mm2_test_helpers/src/for_tests.rs index 490d312269..f98fe3041c 100644 --- a/mm2src/mm2_test_helpers/src/for_tests.rs +++ b/mm2src/mm2_test_helpers/src/for_tests.rs @@ -427,6 +427,11 @@ impl Mm2TestConf { } pub fn no_login_node(coins: &Json, seednodes: &[&str]) -> Self { + assert!( + !seednodes.is_empty(), + "Invalid Test Setup: A no-login node requires at least one seednode." + ); + Mm2TestConf { conf: json!({ "gui": "nogui", @@ -2985,6 +2990,20 @@ pub async fn get_wallet_names(mm: &MarketMakerIt) -> GetWalletNamesResult { res.result } +pub async fn delete_wallet(mm: &MarketMakerIt, wallet_name: &str, password: &str) -> (StatusCode, String, HeaderMap) { + mm.rpc(&json!({ + "userpass": mm.userpass, + "method": "delete_wallet", + "mmrpc": "2.0", + "params": { + "wallet_name": wallet_name, + "password": password, + } + })) + .await + .unwrap() +} + pub async fn max_maker_vol(mm: &MarketMakerIt, coin: &str) -> RpcResponse { let rc = mm .rpc(&json!({