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:
2026-07-09 15:57:23 -05:00
parent 82e61da62f
commit 72bf59149d
13 changed files with 524 additions and 14 deletions

View File

@@ -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

View File

@@ -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
View 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
View 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