feat(wallet): per-wallet data isolation foundation (P1)
First phase of multi-wallet support: keep each wallet's data separate so loading
a different wallet no longer shows the previous one's data, and lay the metadata
groundwork for the wallet-files list.
- Address book scoped per wallet: AddressBookEntry gains a "scope" ("global" or a
wallet-identity hash); the Contacts tab shows global + current-wallet contacts
(App::activeWalletIdentityHash) with a "show in every wallet" toggle + globe
badge. Legacy entries migrate to "global". Scope-aware de-dup allows the same
address across different wallets.
- Wallet metadata index (data/wallet_index -> wallets.json): file-keyed cache of
balance / address count / identity / size / last-opened / synced-here, since
those can't be read off a wallet.dat without loading it. Populated on connect +
address refresh (change-detecting upsert). Plus an active_wallet_file setting
for the -wallet=<name> switch coming in P2.
Unit-tested (testAddressBookScope, testWalletIndex): migration, visibility filter,
scope-aware de-dup, upsert change-detection, save/reload round-trip.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -51,7 +51,10 @@ bool AddressBook::load()
|
||||
e.label = entry.value("label", "");
|
||||
e.address = entry.value("address", "");
|
||||
e.notes = entry.value("notes", "");
|
||||
|
||||
// Legacy entries (no "scope") migrate to "global" so nothing disappears when
|
||||
// multi-wallet scoping lands — a contact you already had stays visible everywhere.
|
||||
e.scope = entry.value("scope", "global");
|
||||
|
||||
if (!e.address.empty()) {
|
||||
entries_.push_back(e);
|
||||
}
|
||||
@@ -82,6 +85,7 @@ bool AddressBook::save()
|
||||
e["label"] = entry.label;
|
||||
e["address"] = entry.address;
|
||||
e["notes"] = entry.notes;
|
||||
e["scope"] = entry.scope.empty() ? std::string("global") : entry.scope;
|
||||
j["entries"].push_back(e);
|
||||
}
|
||||
|
||||
@@ -103,11 +107,12 @@ bool AddressBook::save()
|
||||
|
||||
bool AddressBook::addEntry(const AddressBookEntry& entry)
|
||||
{
|
||||
// Check for duplicate address
|
||||
if (findByAddress(entry.address) >= 0) {
|
||||
// Reject a duplicate only within the same visible set (same wallet or global) — the same
|
||||
// address may legitimately be a contact in two different wallets.
|
||||
if (hasVisibleDuplicate(entry.address, entry.scope)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
entries_.push_back(entry);
|
||||
return save();
|
||||
}
|
||||
@@ -118,12 +123,11 @@ bool AddressBook::updateEntry(size_t index, const AddressBookEntry& entry)
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for duplicate address (excluding current entry)
|
||||
int existing = findByAddress(entry.address);
|
||||
if (existing >= 0 && static_cast<size_t>(existing) != index) {
|
||||
// Check for a duplicate visible alongside this entry's scope (excluding the entry being edited)
|
||||
if (hasVisibleDuplicate(entry.address, entry.scope, static_cast<int>(index))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
entries_[index] = entry;
|
||||
return save();
|
||||
}
|
||||
@@ -148,5 +152,19 @@ int AddressBook::findByAddress(const std::string& address) const
|
||||
return -1;
|
||||
}
|
||||
|
||||
bool AddressBook::hasVisibleDuplicate(const std::string& address, const std::string& scope,
|
||||
int excludeIndex) const
|
||||
{
|
||||
AddressBookEntry probe; probe.scope = scope; // reuse the isGlobal()/scope logic
|
||||
for (size_t i = 0; i < entries_.size(); i++) {
|
||||
if (static_cast<int>(i) == excludeIndex) continue;
|
||||
const auto& e = entries_[i];
|
||||
if (e.address != address) continue;
|
||||
// Collides if they'd ever be shown together: same wallet scope, or either is global.
|
||||
if (e.isGlobal() || probe.isGlobal() || e.scope == scope) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -17,10 +17,21 @@ struct AddressBookEntry {
|
||||
std::string label;
|
||||
std::string address;
|
||||
std::string notes;
|
||||
|
||||
// Per-wallet visibility scope: "global" (shown in every wallet) or a wallet-identity hash
|
||||
// (shown only when that wallet is the active one). Empty is treated as "global" so legacy
|
||||
// entries — written before multi-wallet scoping — keep showing everywhere.
|
||||
std::string scope = "global";
|
||||
|
||||
AddressBookEntry() = default;
|
||||
AddressBookEntry(const std::string& l, const std::string& a, const std::string& n = "")
|
||||
: label(l), address(a), notes(n) {}
|
||||
AddressBookEntry(const std::string& l, const std::string& a, const std::string& n = "",
|
||||
const std::string& s = "global")
|
||||
: label(l), address(a), notes(n), scope(s) {}
|
||||
|
||||
bool isGlobal() const { return scope.empty() || scope == "global"; }
|
||||
// Visible under the wallet whose identity hash is walletHash (or globally).
|
||||
bool visibleInWallet(const std::string& walletHash) const {
|
||||
return isGlobal() || scope == walletHash;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -73,12 +84,21 @@ public:
|
||||
bool removeEntry(size_t index);
|
||||
|
||||
/**
|
||||
* @brief Find entry by address
|
||||
* @brief Find entry by address (any scope). Used for contact-label lookups.
|
||||
* @param address Address to search for
|
||||
* @return Index or -1 if not found
|
||||
*/
|
||||
int findByAddress(const std::string& address) const;
|
||||
|
||||
/**
|
||||
* @brief Is there an existing entry with this address that would be visible ALONGSIDE a new
|
||||
* entry of the given scope? (Same wallet, or either side global.) Used to reject
|
||||
* duplicates within a wallet while still allowing the same address in different wallets.
|
||||
* @param excludeIndex Storage index to skip (for edit), or -1.
|
||||
*/
|
||||
bool hasVisibleDuplicate(const std::string& address, const std::string& scope,
|
||||
int excludeIndex = -1) const;
|
||||
|
||||
/**
|
||||
* @brief Get all entries
|
||||
*/
|
||||
|
||||
159
src/data/wallet_index.cpp
Normal file
159
src/data/wallet_index.cpp
Normal file
@@ -0,0 +1,159 @@
|
||||
// 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/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.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.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 (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["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::Platform::writeFileAtomically(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
|
||||
68
src/data/wallet_index.h
Normal file
68
src/data/wallet_index.h
Normal file
@@ -0,0 +1,68 @@
|
||||
// DragonX Wallet - ImGui Edition
|
||||
// Copyright 2024-2026 The Hush Developers
|
||||
// Released under the GPLv3
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
/**
|
||||
* @brief Cached, file-keyed metadata for one wallet file.
|
||||
*
|
||||
* A wallet.dat on disk only reveals its size + mtime; balance and address count require the daemon
|
||||
* to have loaded it. So those are cached here after each load and shown in the wallet-files list
|
||||
* (P2) even when the wallet isn't the active one. walletIdentityHash bridges file -> per-wallet data
|
||||
* (address book, tx history) so the right caches can be selected before/after a switch.
|
||||
*/
|
||||
struct WalletIndexEntry {
|
||||
std::string fileName; // plain wallet filename in the datadir (e.g. "wallet.dat")
|
||||
std::string displayName; // user-facing name (defaults to fileName)
|
||||
std::string walletIdentityHash; // address-derived identity of the last load; "" = unknown
|
||||
double cachedBalance = -1.0; // last-known total balance; < 0 = unknown (never opened)
|
||||
long long cachedAddressCount = -1;// < 0 = unknown
|
||||
long long lastOpenedEpoch = 0; // unix seconds of last open; 0 = never opened
|
||||
long long sizeBytesAtLastOpen = 0;
|
||||
bool syncedHere = false; // loaded in this datadir before -> catch-up on switch (no full rescan)
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Persistent wallet-files metadata index (wallets.json, in the config dir).
|
||||
*
|
||||
* Keyed by wallet file name. Populated after each load (P1b) and read by the wallet-files list (P2).
|
||||
* Also remembers extra folders the user added to scan for wallet files. Not encrypted — it holds
|
||||
* only file names + coarse metadata (no keys/addresses).
|
||||
*/
|
||||
class WalletIndex {
|
||||
public:
|
||||
bool load();
|
||||
bool save();
|
||||
static std::string getDefaultPath();
|
||||
|
||||
const std::vector<WalletIndexEntry>& entries() const { return entries_; }
|
||||
const std::vector<std::string>& extraFolders() const { return extra_folders_; }
|
||||
|
||||
/**
|
||||
* @brief Insert or update the entry for e.fileName.
|
||||
* @return true if a new entry was added or any field changed (so the caller can skip save()).
|
||||
*/
|
||||
bool upsert(const WalletIndexEntry& e);
|
||||
|
||||
/** @brief Find the entry for a wallet file, or nullptr. */
|
||||
const WalletIndexEntry* find(const std::string& fileName) const;
|
||||
|
||||
/** @brief Add/remove an extra scan folder. Returns true if the set changed. */
|
||||
bool addExtraFolder(const std::string& dir);
|
||||
bool removeExtraFolder(const std::string& dir);
|
||||
|
||||
private:
|
||||
std::vector<WalletIndexEntry> entries_;
|
||||
std::vector<std::string> extra_folders_;
|
||||
std::string file_path_;
|
||||
};
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
Reference in New Issue
Block a user