feat(wallet): wallet-files list + switching (P2)

Second phase of multi-wallet: list wallet files and switch the active one.

- Daemon plumbing: EmbeddedDaemon::setWalletFile -> start() passes -wallet=<name>
  (non-default only; skipped during the isolated seed-migration start), and
  DaemonController::syncSettings pushes active_wallet_file on each start.
- App::switchToWallet: persist the new active wallet, then stop + restart the
  node on -wallet=<name> (rescan only if it was never synced in this datadir).
  Reuses the seed-adopt restart coordination; WalletState clears on disconnect
  and the P1 identity-scoped caches re-key, so no previous-wallet data leaks.
  Guarded: full-node, embedded daemon, not mid-restart, no pending send.
- Wallets dialog (Settings -> Backup & Data -> "Wallets…"): a table of wallet
  files (datadir wallet*.dat + user-added folders' *.dat) with size (disk) and
  cached balance / address count / last-opened (wallet index), a current/Active
  badge, Open (switch), and Add folder. Out-of-datadir wallets show "import to
  open" (P3). Added as a sweep surface.
- Mining payout safety: non-destructive warning if the pool-mode worker looks
  like a DragonX address not in the current wallet (stale after a switch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 16:20:41 -05:00
parent 72bf59149d
commit e8a8ce68b2
11 changed files with 353 additions and 0 deletions

View File

@@ -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=<name>).
#pragma once
#include <cstdint>
#include <filesystem>
#include <string>
#include <vector>
#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<int>(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<uint64_t>(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<long long>(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<WalletRow> s_rows;
};
} // namespace ui
} // namespace dragonx