The full UI sweep (dev builds only) ran its demo surfaces against the
real profile. Its teardown deleted wallet-{cold,trading,mining,donations,
2023,payroll}.dat from the real datadir, it created zero-filled wallet
files and picker-demo/extra-wallets folders, its demo wallet-index upsert
re-minted the active wallet's scopeId (hiding "w:"-scoped contacts), the
Contacts tab could re-attach legacy-scoped contacts to such a throwaway
id in addressbook.json, and it persisted the demo active wallet file and
Mining pool worker.
- Preflight: a full sweep is refused (toast + log line) while a real
workflow is in flight: a pending seed_migration_* setting or open
migrate dialog, a wallet switch, an encryption/import workflow, an
encrypt/PIN/lock operation, the seed-backup dialog, or a bootstrap.
The DRAGONX_FULL_SWEEP hook then exits as for an empty catalog.
- Persistence latches: AddressBook (snapshot on the first sweepBegin,
save/load latched while a demo-contacts surface is shown), WalletIndex
and Settings (active wallet file, contacts view mode, pool URL/worker)
for the whole sweep. Settings::sweepEnd() is the last statement of
clearDemoWalletData and flushes once only if a save was suppressed; a
quit mid-sweep writes nothing to the profile (the 0700 sandbox, with
zero-filled demo files only, may stay in the temp dir).
- Scope ids: during a full sweep activeWalletScopeId() returns the
persisted id or "" and never mints one, so the Contacts tab cannot
move legacy-scoped contacts to a throwaway id. The only address-book
write a sweep can still cause is the one-time legacy re-attach to the
wallet's persisted id, which opening Contacts does anyway.
- Sandbox: demo wallet files and folders go to a fresh random 0700 dir
under the temp dir (exclusive create, a pre-existing path or symlink
is refused), removed at sweep end. The teardown that deleted those six
wallet files is gone.
- Cleanup: the migrate-to-seed state, wizard phase, demo chat
conversations and a fabricated demo chat identity are reset at sweep
end; the seed-backup surface always shows the demo phrase and a real
z_exportmnemonic result is dropped while the sweep runs.
- i18n: one new key (screenshot_sweep_refused) in i18n.cpp and the 8
language files.
Tests: the address book snapshots only on the first sweepBegin, keeps
addressbook.json byte-identical while latched and restores the real
book; the wallet index keeps wallets.json and the real scope id, also
with the background writer attached; Settings restores the latched
values and flushes once only when a save was suppressed; the sandbox
helper refuses a pre-existing path or symlink and removes only a
prefixed dir, never a symlink's target.
Release note: v2.0.x UI sweeps could delete wallet-{cold,trading,mining,
donations,2023,payroll}.dat from the data directory and re-scope
contacts; this cannot be undone automatically.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
166 lines
5.3 KiB
C++
166 lines
5.3 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#include "wallet_index.h"
|
|
|
|
#include <nlohmann/json.hpp>
|
|
#include <algorithm>
|
|
#include <filesystem>
|
|
#include <fstream>
|
|
|
|
#include "../util/coalescing_writer.h"
|
|
#include "../util/logger.h"
|
|
#include "../util/platform.h"
|
|
|
|
namespace fs = std::filesystem;
|
|
using json = nlohmann::json;
|
|
|
|
namespace dragonx {
|
|
namespace data {
|
|
|
|
namespace {
|
|
// A field-wise "did anything change" check so we only rewrite wallets.json when needed.
|
|
bool sameEntry(const WalletIndexEntry& a, const WalletIndexEntry& b)
|
|
{
|
|
return a.fileName == b.fileName
|
|
&& a.displayName == b.displayName
|
|
&& a.walletIdentityHash == b.walletIdentityHash
|
|
&& a.scopeId == b.scopeId
|
|
&& a.cachedBalance == b.cachedBalance
|
|
&& a.cachedAddressCount == b.cachedAddressCount
|
|
&& a.lastOpenedEpoch == b.lastOpenedEpoch
|
|
&& a.sizeBytesAtLastOpen == b.sizeBytesAtLastOpen
|
|
&& a.syncedHere == b.syncedHere;
|
|
}
|
|
} // namespace
|
|
|
|
std::string WalletIndex::getDefaultPath()
|
|
{
|
|
// Co-located with settings.json / addressbook.json in the per-variant config dir.
|
|
const std::string dir = util::Platform::getConfigDir();
|
|
fs::create_directories(dir);
|
|
return (fs::path(dir) / "wallets.json").string();
|
|
}
|
|
|
|
bool WalletIndex::load()
|
|
{
|
|
file_path_ = getDefaultPath();
|
|
entries_.clear();
|
|
extra_folders_.clear();
|
|
|
|
std::ifstream file(file_path_);
|
|
if (!file.is_open()) return true; // no file yet is fine
|
|
|
|
try {
|
|
json j;
|
|
file >> j;
|
|
|
|
if (j.contains("entries") && j["entries"].is_array()) {
|
|
for (const auto& e : j["entries"]) {
|
|
WalletIndexEntry w;
|
|
w.fileName = e.value("file", "");
|
|
if (w.fileName.empty()) continue;
|
|
w.displayName = e.value("name", w.fileName);
|
|
w.walletIdentityHash = e.value("identity", "");
|
|
w.scopeId = e.value("scopeId", "");
|
|
w.cachedBalance = e.value("balance", -1.0);
|
|
w.cachedAddressCount = e.value("addresses", (long long)-1);
|
|
w.lastOpenedEpoch = e.value("lastOpened", (long long)0);
|
|
w.sizeBytesAtLastOpen = e.value("size", (long long)0);
|
|
w.syncedHere = e.value("syncedHere", false);
|
|
entries_.push_back(std::move(w));
|
|
}
|
|
}
|
|
if (j.contains("extraFolders") && j["extraFolders"].is_array()) {
|
|
for (const auto& d : j["extraFolders"]) {
|
|
if (d.is_string() && !d.get<std::string>().empty())
|
|
extra_folders_.push_back(d.get<std::string>());
|
|
}
|
|
}
|
|
DEBUG_LOGF("Wallet index loaded: %zu wallets, %zu extra folders\n",
|
|
entries_.size(), extra_folders_.size());
|
|
return true;
|
|
} catch (const std::exception& e) {
|
|
DEBUG_LOGF("Error loading wallet index: %s\n", e.what());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool WalletIndex::save()
|
|
{
|
|
if (sweep_active_) return true; // UI sweep: demo entries never reach disk
|
|
if (file_path_.empty()) file_path_ = getDefaultPath();
|
|
|
|
try {
|
|
json j;
|
|
j["entries"] = json::array();
|
|
for (const auto& w : entries_) {
|
|
json e;
|
|
e["file"] = w.fileName;
|
|
e["name"] = w.displayName;
|
|
e["identity"] = w.walletIdentityHash;
|
|
e["scopeId"] = w.scopeId;
|
|
e["balance"] = w.cachedBalance;
|
|
e["addresses"] = w.cachedAddressCount;
|
|
e["lastOpened"] = w.lastOpenedEpoch;
|
|
e["size"] = w.sizeBytesAtLastOpen;
|
|
e["syncedHere"] = w.syncedHere;
|
|
j["entries"].push_back(std::move(e));
|
|
}
|
|
j["extraFolders"] = extra_folders_;
|
|
|
|
// No secrets here (file names + coarse metadata) but keep it owner-only for consistency.
|
|
if (!util::persistFileAtomically(writer_, /*waitForDisk=*/false, file_path_, j.dump(2),
|
|
/*restrictPermissions=*/true)) {
|
|
DEBUG_LOGF("Could not write wallet index: %s\n", file_path_.c_str());
|
|
return false;
|
|
}
|
|
return true;
|
|
} catch (const std::exception& e) {
|
|
DEBUG_LOGF("Error saving wallet index: %s\n", e.what());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool WalletIndex::upsert(const WalletIndexEntry& e)
|
|
{
|
|
for (auto& w : entries_) {
|
|
if (w.fileName == e.fileName) {
|
|
if (sameEntry(w, e)) return false; // nothing changed -> caller can skip save()
|
|
w = e;
|
|
return true;
|
|
}
|
|
}
|
|
entries_.push_back(e);
|
|
return true;
|
|
}
|
|
|
|
const WalletIndexEntry* WalletIndex::find(const std::string& fileName) const
|
|
{
|
|
for (const auto& w : entries_) {
|
|
if (w.fileName == fileName) return &w;
|
|
}
|
|
return nullptr;
|
|
}
|
|
|
|
bool WalletIndex::addExtraFolder(const std::string& dir)
|
|
{
|
|
if (dir.empty()) return false;
|
|
if (std::find(extra_folders_.begin(), extra_folders_.end(), dir) != extra_folders_.end())
|
|
return false;
|
|
extra_folders_.push_back(dir);
|
|
return true;
|
|
}
|
|
|
|
bool WalletIndex::removeExtraFolder(const std::string& dir)
|
|
{
|
|
auto it = std::find(extra_folders_.begin(), extra_folders_.end(), dir);
|
|
if (it == extra_folders_.end()) return false;
|
|
extra_folders_.erase(it);
|
|
return true;
|
|
}
|
|
|
|
} // namespace data
|
|
} // namespace dragonx
|