diff --git a/src/app.cpp b/src/app.cpp index 19624c5..65b85eb 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -54,6 +54,7 @@ #include "ui/windows/bootstrap_download_dialog.h" #include "ui/windows/xmrig_download_dialog.h" #include "ui/windows/daemon_download_dialog.h" +#include "ui/windows/wallets_dialog.h" #include "ui/windows/console_tab.h" #include "ui/pages/settings_page.h" #include "ui/theme.h" @@ -1990,6 +1991,7 @@ void App::render() ui::BootstrapDownloadDialog::render(); ui::XmrigDownloadDialog::render(); ui::DaemonUpdateDialog::render(); + ui::WalletsDialog::render(); // Windows Defender antivirus help dialog renderAntivirusHelpDialog(); diff --git a/src/app.h b/src/app.h index f3db173..d3c8c02 100644 --- a/src/app.h +++ b/src/app.h @@ -202,6 +202,9 @@ public: // per-wallet data (e.g. address-book contacts). Empty until addresses are known (pre-connect). std::string activeWalletIdentityHash() const; + data::WalletIndex& walletIndex() { return wallet_index_; } + const data::WalletIndex& walletIndex() const { return wallet_index_; } + // Connection state (convenience wrappers) bool isConnected() const { return state_.connected; } int getBlockHeight() const { return state_.sync.blocks; } @@ -447,6 +450,10 @@ public: * Shows "Restarting daemon..." in the loading overlay while the daemon cycles. */ void restartDaemon(); + // Switch the active wallet: persist the new -wallet=, stop the node, restart on it + // (rescan only if it was never synced in this datadir). Per-wallet data follows automatically + // via the identity-scoped caches (P1). No-op if that wallet is already active. + void switchToWallet(const std::string& walletFile); // Wallet encryption helpers void encryptWalletWithPassphrase(const std::string& passphrase); diff --git a/src/app_network.cpp b/src/app_network.cpp index c5f28a3..3e856b7 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -1007,6 +1007,58 @@ void App::updateWalletIndexForActiveWallet(bool markOpened) if (wallet_index_.upsert(e)) wallet_index_.save(); } +void App::switchToWallet(const std::string& walletFile) +{ + if (!supportsFullNodeLifecycleActions()) { + ui::Notifications::instance().warning("Full-node lifecycle actions are unavailable in lite build"); + return; + } + if (walletFile.empty() || !settings_) return; + if (settings_->getActiveWalletFile() == walletFile) { + ui::Notifications::instance().info("That wallet is already open."); + return; + } + if (daemon_restarting_) { + ui::Notifications::instance().warning("The node is busy restarting — try again in a moment."); + return; + } + if (!isUsingEmbeddedDaemon()) { + ui::Notifications::instance().warning("Switching wallets needs the embedded daemon — stop any external dragonxd first."); + return; + } + if (hasTransactionSendProgress()) { + ui::Notifications::instance().warning("Finish or cancel the pending send before switching wallets."); + return; + } + + // Rescan only if this wallet was never synced in this datadir (freshly imported/new). One we've + // loaded here before just catches up from its recorded block on start — fast. + bool needRescan = true; + if (const auto* e = wallet_index_.find(walletFile)) needRescan = !e->syncedHere; + + settings_->setActiveWalletFile(walletFile); + settings_->save(); + + // Same restart coordination as the seed-adopt flow: gate the main-loop reconnect, disconnect, + // then stop + restart on a background thread. syncSettings() re-reads active_wallet_file on + // start, so the daemon comes up on -wallet=. WalletState clears on disconnect and the + // per-wallet caches re-key off the new identity (P1), so no previous-wallet data leaks through. + daemon_restarting_ = true; + if (rpc_ && rpc_->isConnected()) rpc_->disconnect(); + onDisconnected("Switching wallet"); + ui::Notifications::instance().info("Switching wallet — the node will restart…"); + + async_tasks_.submit("Switch wallet", [this, needRescan](const util::AsyncTaskManager::Token&) { + // Stop the node fully so it releases the datadir .lock + RPC port before we relaunch. + stopEmbeddedDaemon(); + for (int i = 0; i < 60 && isEmbeddedDaemonRunning(); ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + if (needRescan && daemon_controller_) daemon_controller_->setRescanOnNextStart(true); + if (!shutting_down_) startEmbeddedDaemon(); + daemon_restarting_ = false; // re-arm reconnect once the new daemon has been launched + }); +} + void App::wipePendingTransactionHistoryCachePassphrase() { if (!pending_transaction_history_cache_passphrase_.empty()) { diff --git a/src/app_sweep.cpp b/src/app_sweep.cpp index 13758fc..882a6cd 100644 --- a/src/app_sweep.cpp +++ b/src/app_sweep.cpp @@ -17,6 +17,7 @@ #include "ui/notifications.h" #include "ui/sidebar.h" #include "ui/windows/send_tab.h" +#include "ui/windows/wallets_dialog.h" #include "util/platform.h" #include "wallet/wallet_capabilities.h" @@ -261,6 +262,30 @@ void App::buildSweepCatalog() mig("modal-migrate-done", SeedMigrationStep::Done, nullptr); mig("modal-migrate-error", SeedMigrationStep::Error, [](App& a) { a.seed_migration_status_ = "Example error: the daemon did not respond."; }); + + // Multi-wallet: the wallet-files list. Drop a couple of demo wallet files in the (throwaway) + // datadir + cache their metadata so the list renders populated. + add("modal-wallets", ui::NavPage::Settings, + [](App& a) { + std::error_code ec; + const std::string dd = util::Platform::getDragonXDataDir(); + fs::create_directories(dd, ec); + if (!fs::exists(dd + "/wallet.dat", ec)) + std::ofstream(dd + "/wallet.dat", std::ios::binary) << std::string(122880, '\0'); + if (!fs::exists(dd + "/wallet-savings.dat", ec)) + std::ofstream(dd + "/wallet-savings.dat", std::ios::binary) << std::string(65536, '\0'); + data::WalletIndexEntry e1; e1.fileName = "wallet.dat"; e1.displayName = "wallet.dat"; + e1.cachedBalance = 15.7526; e1.cachedAddressCount = 4; + e1.lastOpenedEpoch = 1720000000; e1.syncedHere = true; + data::WalletIndexEntry e2; e2.fileName = "wallet-savings.dat"; e2.displayName = "wallet-savings.dat"; + e2.cachedBalance = 250.0; e2.cachedAddressCount = 2; + e2.lastOpenedEpoch = 1719400000; e2.syncedHere = true; + a.walletIndex().upsert(e1); + a.walletIndex().upsert(e2); + if (a.settings()) a.settings()->setActiveWalletFile("wallet.dat"); + ui::WalletsDialog::show(&a); + }, + [](App&) { ui::WalletsDialog::hide(); }); } // First-run wizard — one per meaningful phase (full-node only; blocks all other UI while shown). diff --git a/src/daemon/daemon_controller.cpp b/src/daemon/daemon_controller.cpp index 183ae79..1b82d36 100644 --- a/src/daemon/daemon_controller.cpp +++ b/src/daemon/daemon_controller.cpp @@ -23,6 +23,7 @@ void DaemonController::syncSettings(const config::Settings* settings) if (!settings) return; daemon_->setDebugCategories(settings->getDebugCategories()); daemon_->setMaxConnections(settings->getMaxConnections()); + daemon_->setWalletFile(settings->getActiveWalletFile()); } bool DaemonController::start(const config::Settings* settings) diff --git a/src/daemon/embedded_daemon.cpp b/src/daemon/embedded_daemon.cpp index 2779bd3..26d0234 100644 --- a/src/daemon/embedded_daemon.cpp +++ b/src/daemon/embedded_daemon.cpp @@ -488,6 +488,14 @@ bool EmbeddedDaemon::start(const std::string& binary_path) args.push_back("-maxconnections=" + std::to_string(max_connections_)); } + // Active wallet file (multi-wallet). The daemon loads /. Only pass it for a + // non-default name so the common case's command line is unchanged; skip during an isolated + // start (seed migration manages its own throwaway wallet). + if (!wallet_file_.empty() && wallet_file_ != "wallet.dat" && override_datadir_.empty()) { + DEBUG_LOGF("[INFO] Loading wallet file: %s\n", wallet_file_.c_str()); + args.push_back("-wallet=" + wallet_file_); + } + // Add wallet-repair flag if requested (one-shot). -zapwallettxes=2 wipes all wallet tx/note // records and rebuilds them from the chain; it implies -rescan, so don't also pass -rescan. if (zap_on_next_start_.exchange(false)) { diff --git a/src/daemon/embedded_daemon.h b/src/daemon/embedded_daemon.h index bd4d038..a16af1e 100644 --- a/src/daemon/embedded_daemon.h +++ b/src/daemon/embedded_daemon.h @@ -183,6 +183,12 @@ public: void setRescanOnNextStart(bool v) { rescan_on_next_start_ = v; } bool rescanOnNextStart() const { return rescan_on_next_start_.load(); } + // Active wallet file (multi-wallet). Passed to the daemon as -wallet= so it loads + // /; empty or "wallet.dat" keeps the daemon default (no arg). Must be a plain + // filename in the datadir — the daemon rejects paths. + void setWalletFile(const std::string& v) { wallet_file_ = v; } + std::string walletFile() const { return wallet_file_; } + /** * @brief Request a wallet repair (-zapwallettxes=2) on the next daemon start. This deletes all * wallet transaction/note records and rebuilds them from the chain (keys are kept); the @@ -251,6 +257,7 @@ private: std::atomic should_stop_{false}; std::set debug_categories_; int max_connections_ = 0; // 0 = daemon default + std::string wallet_file_; // -wallet= for the active wallet; empty/"wallet.dat" = default std::atomic crash_count_{0}; // consecutive crash counter std::atomic rescan_on_next_start_{false}; // -rescan flag for next start std::atomic zap_on_next_start_{false}; // -zapwallettxes=2 flag for next start diff --git a/src/ui/pages/settings_page.cpp b/src/ui/pages/settings_page.cpp index 74a173d..fdcd8d9 100644 --- a/src/ui/pages/settings_page.cpp +++ b/src/ui/pages/settings_page.cpp @@ -40,6 +40,7 @@ #include "../windows/export_transactions_dialog.h" #include "../windows/bootstrap_download_dialog.h" #include "../windows/daemon_download_dialog.h" +#include "../windows/wallets_dialog.h" #include "../../embedded/IconsMaterialDesign.h" #include "imgui.h" #include @@ -1340,6 +1341,11 @@ void RenderSettingsPage(App* app) { if (TactileButton(TR("seed_migrate_button"), ImVec2(0, 0), btnFont)) app->showSeedMigrationDialog(); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_seed_migrate")); + // Multi-wallet: list wallet files + switch the active one. + ImGui::SameLine(0, scaledSp); + if (TactileButton(TR("wallets_button"), ImVec2(0, 0), btnFont)) + ui::WalletsDialog::show(app); + if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_wallets_button")); } ImGui::SameLine(0, scaledSp); if (TactileButton(r1[4], ImVec2(0, 0), btnFont)) diff --git a/src/ui/windows/mining_tab.cpp b/src/ui/windows/mining_tab.cpp index 6e86d56..49b8774 100644 --- a/src/ui/windows/mining_tab.cpp +++ b/src/ui/windows/mining_tab.cpp @@ -277,6 +277,23 @@ static void RenderMiningTabContent(App* app) RenderMiningModeToggle(app, state, mining, dl, capFont, ovFont, dp, hs, gap, availWidth, s_pool_mode, s_pool_url, s_pool_worker, s_pool_settings_dirty); + // Payout safety (multi-wallet): in pool mode, warn if the worker looks like a DragonX address + // that isn't in the CURRENT wallet — e.g. left over from a wallet you switched away from, so + // rewards would land in the wrong wallet. Non-destructive: we only warn, never overwrite (it + // could be a deliberate external payout). + if (s_pool_mode) { + std::string w(s_pool_worker); + while (!w.empty() && (w.front()==' '||w.front()=='\t'||w.front()=='\n'||w.front()=='\r')) w.erase(w.begin()); + while (!w.empty() && (w.back()==' '||w.back()=='\t'||w.back()=='\n'||w.back()=='\r')) w.pop_back(); + const bool looksLikeAddr = w.size() > 10 && (w.rfind("zs", 0) == 0 || w[0] == 'R'); + bool inWallet = false; + for (const auto& a : state.addresses) if (a.address == w) { inWallet = true; break; } + if (looksLikeAddr && !inWallet && !state.addresses.empty()) { + ImGui::Dummy(ImVec2(0, gap)); + Type().textColored(TypeStyle::Caption, Warning(), TR("mining_payout_foreign")); + } + } + // ================================================================ // CONTROLS — Glass card with CPU core grid (no heading) // ================================================================ diff --git a/src/ui/windows/wallets_dialog.h b/src/ui/windows/wallets_dialog.h new file mode 100644 index 0000000..6ff5de7 --- /dev/null +++ b/src/ui/windows/wallets_dialog.h @@ -0,0 +1,207 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// Wallet-files list + switcher (multi-wallet P2). Lists wallet files in the datadir (and any +// user-added folders) with cached metadata (size from disk; balance / address count / last-opened +// from the wallet index, since those can't be read off a wallet.dat without loading it). "Open" +// switches the active wallet via App::switchToWallet (daemon restart on -wallet=). + +#pragma once + +#include +#include +#include +#include + +#include "imgui.h" + +#include "../../app.h" +#include "../../config/settings.h" +#include "../../util/i18n.h" +#include "../../util/platform.h" +#include "../../util/text_format.h" +#include "../../embedded/IconsMaterialDesign.h" +#include "../notifications.h" +#include "../layout.h" +#include "../material/colors.h" +#include "../material/draw_helpers.h" +#include "../material/type.h" + +namespace dragonx { +namespace ui { + +class WalletsDialog { +public: + static void show(App* app) { + s_open = true; + s_app = app; + s_needScan = true; + s_newFolder[0] = '\0'; + } + + static void hide() { s_open = false; } + + static void render() { + if (!s_open || !s_app) return; + using namespace material; + App* app = s_app; + const float dp = Layout::dpiScale(); + + if (s_needScan) { scan(); s_needScan = false; } + + if (BeginOverlayDialog(TR("wallets_title"), &s_open, 780.0f, 0.94f)) { + Type().text(TypeStyle::H6, TR("wallets_title")); + ImGui::Dummy(ImVec2(0, Layout::spacingXs())); + Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_intro")); + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + + const std::string active = app->settings() ? app->settings()->getActiveWalletFile() : "wallet.dat"; + + // Leave room below the table for the add-folder row + footer buttons. + float tableH = ImGui::GetContentRegionAvail().y - 130.0f * dp; + if (tableH < 120.0f * dp) tableH = 120.0f * dp; + + const ImGuiTableFlags tflags = ImGuiTableFlags_ScrollY | ImGuiTableFlags_RowBg | + ImGuiTableFlags_BordersInnerH; + if (ImGui::BeginTable("##wallets", 6, tflags, ImVec2(0, tableH))) { + ImGui::TableSetupColumn(TR("wallets_col_name"), ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn(TR("wallets_col_size"), ImGuiTableColumnFlags_WidthFixed, 90.0f * dp); + ImGui::TableSetupColumn(TR("wallets_col_addresses"), ImGuiTableColumnFlags_WidthFixed, 80.0f * dp); + ImGui::TableSetupColumn(TR("wallets_col_balance"), ImGuiTableColumnFlags_WidthFixed, 130.0f * dp); + ImGui::TableSetupColumn(TR("wallets_col_opened"), ImGuiTableColumnFlags_WidthFixed, 120.0f * dp); + ImGui::TableSetupColumn("##action", ImGuiTableColumnFlags_WidthFixed, 110.0f * dp); + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableHeadersRow(); + + for (std::size_t i = 0; i < s_rows.size(); ++i) { + const WalletRow& r = s_rows[i]; + const data::WalletIndexEntry* meta = app->walletIndex().find(r.fileName); + const bool isCurrent = r.inDatadir && r.fileName == active; + + ImGui::TableNextRow(); + ImGui::PushID(static_cast(i)); + + // Name — + "current" badge, or a folder marker for out-of-datadir files. + ImGui::TableNextColumn(); + ImGui::TextUnformatted(r.fileName.c_str()); + if (isCurrent) { + ImGui::SameLine(0, 8.0f * dp); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(Success()), "%s", TR("wallets_current")); + } else if (!r.inDatadir) { + ImGui::SameLine(0, 8.0f * dp); + ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(OnSurfaceMedium()), "%s", ICON_MD_FOLDER); + if (ImGui::IsItemHovered()) Tooltip("%s", r.dir.c_str()); + } + + ImGui::TableNextColumn(); + ImGui::TextUnformatted(util::Platform::formatFileSize(static_cast(r.sizeBytes)).c_str()); + + ImGui::TableNextColumn(); + if (meta && meta->cachedAddressCount >= 0) ImGui::Text("%lld", meta->cachedAddressCount); + else ImGui::TextDisabled("—"); + + ImGui::TableNextColumn(); + if (meta && meta->cachedBalance >= 0.0) ImGui::Text("%.4f", meta->cachedBalance); + else ImGui::TextDisabled("—"); + + ImGui::TableNextColumn(); + if (meta && meta->lastOpenedEpoch > 0) + ImGui::TextUnformatted(util::formatTimeAgo(meta->lastOpenedEpoch).c_str()); + else ImGui::TextDisabled("%s", TR("wallets_never")); + + // Action + ImGui::TableNextColumn(); + if (isCurrent) { + ImGui::TextDisabled("%s", TR("wallets_active")); + } else if (r.inDatadir) { + if (StyledButton(TR("wallets_open"), ImVec2(90.0f * dp, 0))) { + app->switchToWallet(r.fileName); + s_open = false; // node restarts; the main UI shows the reconnect overlay + } + } else { + // Out-of-datadir wallets can't be loaded directly (the daemon rejects paths); + // opening one means importing (copy into the datadir) — a P3 action. + ImGui::TextDisabled("%s", TR("wallets_import_hint")); + if (ImGui::IsItemHovered()) Tooltip("%s", TR("wallets_import_tt")); + } + ImGui::PopID(); + } + ImGui::EndTable(); + } + + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + + // Add a folder to also scan for wallet files (no native picker — a validated path input). + Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("wallets_add_folder")); + ImGui::SetNextItemWidth(-150.0f * dp); + ImGui::InputTextWithHint("##walletFolder", TR("wallets_folder_hint"), s_newFolder, sizeof(s_newFolder)); + ImGui::SameLine(); + if (StyledButton(TR("wallets_add"), ImVec2(120.0f * dp, 0))) { + std::error_code ec; + std::string dir = s_newFolder; + if (!dir.empty() && std::filesystem::is_directory(dir, ec)) { + if (app->walletIndex().addExtraFolder(dir)) app->walletIndex().save(); + s_newFolder[0] = '\0'; + s_needScan = true; + } else { + Notifications::instance().warning(TR("wallets_folder_invalid")); + } + } + + ImGui::Dummy(ImVec2(0, Layout::spacingSm())); + ImGui::Separator(); + if (StyledButton(TR("refresh"), ImVec2(110.0f * dp, 0))) s_needScan = true; + ImGui::SameLine(); + if (StyledButton(TR("close"), ImVec2(110.0f * dp, 0))) s_open = false; + + EndOverlayDialog(); + } + } + +private: + struct WalletRow { + std::string fileName; + std::string dir; + bool inDatadir = false; + long long sizeBytes = 0; + }; + + // Scan the datadir (wallet-prefixed *.dat only, to skip peers.dat / asmap.dat / fee_estimates.dat) + // and each user-added folder (any *.dat). Exception-safe: iterates with error_codes. + static void scan() { + s_rows.clear(); + namespace fs = std::filesystem; + auto addFrom = [](const std::string& dir, bool inDatadir) { + std::error_code ec; + if (dir.empty() || !fs::is_directory(dir, ec)) return; + fs::directory_iterator it(dir, ec), end; + for (; !ec && it != end; it.increment(ec)) { + std::error_code fec; + if (!it->is_regular_file(fec)) continue; + std::string name = it->path().filename().string(); + if (name.size() <= 4 || name.substr(name.size() - 4) != ".dat") continue; + if (inDatadir && name.rfind("wallet", 0) != 0) continue; + WalletRow r; + r.fileName = name; + r.dir = dir; + r.inDatadir = inDatadir; + r.sizeBytes = static_cast(fs::file_size(it->path(), fec)); + s_rows.push_back(std::move(r)); + } + }; + addFrom(util::Platform::getDragonXDataDir(), /*inDatadir=*/true); + if (s_app) + for (const auto& f : s_app->walletIndex().extraFolders()) + addFrom(f, /*inDatadir=*/false); + } + + static inline bool s_open = false; + static inline App* s_app = nullptr; + static inline bool s_needScan = false; + static inline char s_newFolder[512] = ""; + static inline std::vector s_rows; +}; + +} // namespace ui +} // namespace dragonx diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index 29487fc..bc62a8b 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -250,6 +250,26 @@ void I18n::loadBuiltinEnglish() strings_["seed_backup_close"] = "Close"; strings_["seed_backup_reminder"] = "Your wallet has a 24-word recovery seed phrase. Back it up now in Settings → Node & Security."; strings_["seed_migrate_button"] = "Migrate to seed…"; + // Multi-wallet: wallet-files list + switcher + strings_["wallets_button"] = "Wallets…"; + strings_["tt_wallets_button"] = "List your wallet files and switch between them"; + strings_["wallets_title"] = "Wallets"; + strings_["wallets_intro"] = "Wallet files in your data directory (and any folders you add). Open one to switch — the node restarts to load it, and each wallet keeps its own data."; + strings_["wallets_col_name"] = "Wallet"; + strings_["wallets_col_size"] = "Size"; + strings_["wallets_col_addresses"] = "Addresses"; + strings_["wallets_col_balance"] = "Balance"; + strings_["wallets_col_opened"] = "Last opened"; + strings_["wallets_current"] = "current"; + strings_["wallets_active"] = "Active"; + strings_["wallets_open"] = "Open"; + strings_["wallets_never"] = "Never"; + strings_["wallets_import_hint"] = "Import to open"; + strings_["wallets_import_tt"] = "This wallet is outside the data directory. Opening it will copy it in first (coming soon)."; + strings_["wallets_add_folder"] = "Also scan another folder for wallet files:"; + strings_["wallets_folder_hint"] = "/path/to/folder with wallet .dat files"; + strings_["wallets_add"] = "Add folder"; + strings_["wallets_folder_invalid"] = "That folder doesn't exist."; strings_["tt_seed_migrate"] = "Create a new seed-phrase wallet and move your funds into it"; // Migrate-to-seed modal strings_["mig_title"] = "Migrate to a seed wallet"; @@ -1331,6 +1351,7 @@ void I18n::loadBuiltinEnglish() strings_["mining_payout_tooltip"] = "Address to receive mining rewards"; strings_["mining_generate_z_address_hint"] = "Generate a Z address in the Receive tab to use as your payout address"; strings_["mining_pool"] = "Pool"; + strings_["mining_payout_foreign"] = "⚠ This payout address isn't in your current wallet — mined rewards would go to a different wallet. Update it if you switched wallets."; strings_["mining_pool_hashrate"] = "Pool Hashrate"; strings_["mining_pool_url"] = "Pool URL"; // Pool selection mode + auto-balance (mining tab).