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:
@@ -490,6 +490,7 @@ set(APP_SOURCES
|
||||
src/rpc/connection.cpp
|
||||
src/config/settings.cpp
|
||||
src/data/address_book.cpp
|
||||
src/data/wallet_index.cpp
|
||||
src/data/exchange_info.cpp
|
||||
src/util/logger.cpp
|
||||
src/util/async_task_manager.cpp
|
||||
@@ -1062,6 +1063,8 @@ if(BUILD_TESTING)
|
||||
src/util/text_format.cpp
|
||||
src/data/wallet_state.cpp
|
||||
src/data/transaction_history_cache.cpp
|
||||
src/data/address_book.cpp
|
||||
src/data/wallet_index.cpp
|
||||
src/daemon/lifecycle_adapters.cpp
|
||||
src/rpc/connection.cpp
|
||||
src/config/settings.cpp
|
||||
|
||||
@@ -467,6 +467,10 @@ bool App::init()
|
||||
// Idempotent; a missing file is fine (returns true). Purely local — no daemon/RPC dependency.
|
||||
address_book_.load();
|
||||
|
||||
// Load the wallet-files metadata index (wallets.json); populated after each wallet load. Local
|
||||
// only, missing file is fine.
|
||||
wallet_index_.load();
|
||||
|
||||
// If this wallet build bundles a newer daemon than the one already installed, offer to update it
|
||||
// (once per version). Fixes the trap where a wallet update carrying a newer node silently kept
|
||||
// the old binary — e.g. an old daemon lacking z_exportmnemonic blocking migrate-to-seed.
|
||||
|
||||
10
src/app.h
10
src/app.h
@@ -16,6 +16,7 @@
|
||||
#include <nlohmann/json_fwd.hpp>
|
||||
#include "data/transaction_history_cache.h"
|
||||
#include "data/address_book.h"
|
||||
#include "data/wallet_index.h"
|
||||
#include "data/wallet_state.h"
|
||||
#include "rpc/connection.h"
|
||||
#include "services/network_refresh_service.h"
|
||||
@@ -197,6 +198,10 @@ public:
|
||||
data::AddressBook& addressBook() { return address_book_; }
|
||||
const data::AddressBook& addressBook() const { return address_book_; }
|
||||
|
||||
// Hash of the active wallet's identity (derived from its address list), used to scope
|
||||
// per-wallet data (e.g. address-book contacts). Empty until addresses are known (pre-connect).
|
||||
std::string activeWalletIdentityHash() const;
|
||||
|
||||
// Connection state (convenience wrappers)
|
||||
bool isConnected() const { return state_.connected; }
|
||||
int getBlockHeight() const { return state_.sync.blocks; }
|
||||
@@ -555,6 +560,10 @@ private:
|
||||
void applyPendingSendBalanceDeltas(bool includeAggregateBalances);
|
||||
std::string transactionHistoryCacheWalletIdentity() const;
|
||||
bool ensureTransactionHistoryCacheUnlockedFor(const std::string& walletIdentity);
|
||||
// Record the active wallet's cached metadata (balance, address count, identity, size) into the
|
||||
// wallet index (wallets.json). Cheap + throttled: only writes when a value changed. markOpened
|
||||
// stamps last-opened + syncedHere (call once per connect).
|
||||
void updateWalletIndexForActiveWallet(bool markOpened);
|
||||
void unlockTransactionHistoryCacheWithPassphrase(const std::string& passphrase);
|
||||
// Shared main-thread continuations for a wallet unlock attempt, so the passphrase
|
||||
// and PIN paths cannot drift. applyUnlockFailure applies the escalating lockout
|
||||
@@ -965,6 +974,7 @@ private:
|
||||
std::unique_ptr<util::SecureVault> vault_;
|
||||
data::TransactionHistoryCache transaction_history_cache_;
|
||||
data::AddressBook address_book_; // shared contact store; loaded once in init(), self-saves on mutation
|
||||
data::WalletIndex wallet_index_; // per-wallet metadata cache (wallets.json); populated after each load
|
||||
std::string pending_transaction_history_cache_passphrase_;
|
||||
bool transaction_history_cache_loaded_ = false;
|
||||
|
||||
|
||||
@@ -499,7 +499,11 @@ void App::onConnected()
|
||||
daemon_start_error_shown_ = false;
|
||||
daemon_last_seen_crashes_ = 0; // (onConnected resets the daemon's crash count too)
|
||||
connection_status_ = TR("connected");
|
||||
|
||||
|
||||
// Stamp the active wallet as opened in the index (last-opened + size + synced-here). Balance +
|
||||
// address count fill in on the first address refresh (addresses aren't loaded yet here).
|
||||
updateWalletIndexForActiveWallet(/*markOpened=*/true);
|
||||
|
||||
// Reset crash counter on successful connection
|
||||
if (daemon_controller_) {
|
||||
daemon_controller_->resetCrashCount();
|
||||
@@ -959,6 +963,50 @@ std::string App::transactionHistoryCacheWalletIdentity() const
|
||||
shieldedAddresses, transparentAddresses);
|
||||
}
|
||||
|
||||
std::string App::activeWalletIdentityHash() const
|
||||
{
|
||||
const std::string identity = transactionHistoryCacheWalletIdentity();
|
||||
if (identity.empty()) return std::string();
|
||||
return data::TransactionHistoryCache::walletIdentityHash(identity);
|
||||
}
|
||||
|
||||
void App::updateWalletIndexForActiveWallet(bool markOpened)
|
||||
{
|
||||
if (!settings_) return;
|
||||
const std::string file = settings_->getActiveWalletFile();
|
||||
if (file.empty()) return;
|
||||
|
||||
// Start from the existing entry so we preserve fields we're not updating this call.
|
||||
data::WalletIndexEntry e;
|
||||
if (const auto* existing = wallet_index_.find(file)) e = *existing;
|
||||
e.fileName = file;
|
||||
if (e.displayName.empty()) e.displayName = file;
|
||||
|
||||
// Balance + address count are only meaningful once the wallet's addresses are known (identity
|
||||
// non-empty); at connect time they're still 0, so don't cache bogus zeros then.
|
||||
const std::string idHash = state_.connected ? activeWalletIdentityHash() : std::string();
|
||||
if (!idHash.empty()) {
|
||||
e.walletIdentityHash = idHash;
|
||||
e.cachedBalance = state_.totalBalance;
|
||||
e.cachedAddressCount = static_cast<long long>(state_.z_addresses.size() + state_.t_addresses.size());
|
||||
}
|
||||
|
||||
// File size is always readable from disk.
|
||||
std::error_code ec;
|
||||
const std::string path = util::Platform::getDragonXDataDir() + "/" + file;
|
||||
if (std::filesystem::exists(path, ec)) {
|
||||
auto sz = std::filesystem::file_size(path, ec);
|
||||
if (!ec) e.sizeBytesAtLastOpen = static_cast<long long>(sz);
|
||||
}
|
||||
|
||||
if (markOpened) {
|
||||
e.lastOpenedEpoch = static_cast<long long>(std::time(nullptr));
|
||||
e.syncedHere = true; // we've loaded it in this datadir -> catch-up (no full rescan) on switch
|
||||
}
|
||||
|
||||
if (wallet_index_.upsert(e)) wallet_index_.save();
|
||||
}
|
||||
|
||||
void App::wipePendingTransactionHistoryCachePassphrase()
|
||||
{
|
||||
if (!pending_transaction_history_cache_passphrase_.empty()) {
|
||||
@@ -1337,6 +1385,9 @@ void App::refreshAddressData()
|
||||
if (state_.transactions.empty()) loadTransactionHistoryCacheIfAvailable();
|
||||
else storeTransactionHistoryCacheIfAvailable();
|
||||
maybeFinishTransactionSendProgress();
|
||||
// Addresses (hence identity + count) are now known — refresh the wallet index's cached
|
||||
// metadata for the active wallet. Throttled: upsert() only writes on a real change.
|
||||
updateWalletIndexForActiveWallet(/*markOpened=*/false);
|
||||
};
|
||||
}, 3);
|
||||
if (!enqueued.enqueued) return;
|
||||
|
||||
@@ -201,6 +201,7 @@ bool Settings::load(const std::string& path)
|
||||
loadScalar(j, "wizard_completed", wizard_completed_);
|
||||
loadScalar(j, "seed_backup_reminded", seed_backup_reminded_);
|
||||
loadScalar(j, "daemon_update_prompted_size", daemon_update_prompted_size_);
|
||||
loadScalar(j, "active_wallet_file", active_wallet_file_);
|
||||
loadScalar(j, "seed_migration_pending", seed_migration_pending_);
|
||||
loadScalar(j, "seed_migration_dest", seed_migration_dest_);
|
||||
loadScalar(j, "seed_migration_temp_dir", seed_migration_temp_dir_);
|
||||
@@ -440,6 +441,7 @@ bool Settings::save(const std::string& path)
|
||||
j["wizard_completed"] = wizard_completed_;
|
||||
j["seed_backup_reminded"] = seed_backup_reminded_;
|
||||
j["daemon_update_prompted_size"] = daemon_update_prompted_size_;
|
||||
j["active_wallet_file"] = active_wallet_file_;
|
||||
j["seed_migration_pending"] = seed_migration_pending_;
|
||||
j["seed_migration_dest"] = seed_migration_dest_;
|
||||
j["seed_migration_temp_dir"] = seed_migration_temp_dir_;
|
||||
|
||||
@@ -255,6 +255,12 @@ public:
|
||||
long long getDaemonUpdatePromptedSize() const { return daemon_update_prompted_size_; }
|
||||
void setDaemonUpdatePromptedSize(long long v) { daemon_update_prompted_size_ = v; }
|
||||
|
||||
// Active wallet file the daemon loads via -wallet=<name> (multi-wallet). A plain filename in
|
||||
// the datadir; defaults to the daemon's own default. Used at launch to know which wallet we're
|
||||
// on before connect + to scope per-wallet data.
|
||||
std::string getActiveWalletFile() const { return active_wallet_file_; }
|
||||
void setActiveWalletFile(const std::string& v) { active_wallet_file_ = v; }
|
||||
|
||||
// Pending "migrate to a seed wallet" state (Phase 1 created the wallet; a later sweep/adopt
|
||||
// step consumes it). dest = the new wallet's sweep-target z-address; tempDir = its datadir.
|
||||
bool getSeedMigrationPending() const { return seed_migration_pending_; }
|
||||
@@ -468,6 +474,7 @@ private:
|
||||
bool wizard_completed_ = false;
|
||||
bool seed_backup_reminded_ = false;
|
||||
long long daemon_update_prompted_size_ = 0; // bundled daemon size last offered via the update prompt
|
||||
std::string active_wallet_file_ = "wallet.dat"; // -wallet=<name> the daemon loads (multi-wallet)
|
||||
bool seed_migration_pending_ = false;
|
||||
std::string seed_migration_dest_;
|
||||
std::string seed_migration_temp_dir_;
|
||||
|
||||
@@ -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
|
||||
@@ -31,6 +31,7 @@ static int s_confirm_delete_idx = -1; // armed storage index; a 2nd Delete co
|
||||
static char s_edit_label[128] = "";
|
||||
static char s_edit_address[512] = "";
|
||||
static char s_edit_notes[512] = "";
|
||||
static bool s_edit_global = false; // add/edit dialog: "visible in every wallet" toggle
|
||||
static char s_search[128] = "";
|
||||
|
||||
static void copyEditField(char* dest, size_t destSize, const std::string& source) {
|
||||
@@ -69,16 +70,22 @@ void RenderContactsTab(App* app)
|
||||
|
||||
auto& book = app->addressBook();
|
||||
|
||||
// The active wallet's identity hash scopes which contacts show + tags newly-added ones.
|
||||
// Empty pre-connect (addresses unknown) — then we don't filter, to avoid hiding contacts.
|
||||
const std::string activeHash = app->activeWalletIdentityHash();
|
||||
|
||||
auto clearEditFields = []() {
|
||||
s_edit_label[0] = '\0';
|
||||
s_edit_address[0] = '\0';
|
||||
s_edit_notes[0] = '\0';
|
||||
s_edit_global = false; // new contacts default to the current wallet
|
||||
};
|
||||
|
||||
auto loadEditFields = [](const data::AddressBookEntry& entry) {
|
||||
copyEditField(s_edit_label, sizeof(s_edit_label), entry.label);
|
||||
copyEditField(s_edit_address, sizeof(s_edit_address), entry.address);
|
||||
copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes);
|
||||
s_edit_global = entry.isGlobal();
|
||||
};
|
||||
|
||||
bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast<int>(book.size());
|
||||
@@ -149,6 +156,10 @@ void RenderContactsTab(App* app)
|
||||
material::LabeledInputMultiline(TR("notes_optional"), isEdit ? "##EditNotes" : "##AddNotes",
|
||||
s_edit_notes, sizeof(s_edit_notes), ImVec2(formW, notesH));
|
||||
|
||||
ImGui::Spacing();
|
||||
ImGui::Checkbox(TR("contact_global"), &s_edit_global);
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_tt"));
|
||||
|
||||
bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0;
|
||||
float totalActionsW = actionW * 2.0f + actionGap;
|
||||
material::BeginOverlayDialogFooter(totalActionsW);
|
||||
@@ -164,6 +175,9 @@ void RenderContactsTab(App* app)
|
||||
return s;
|
||||
};
|
||||
data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes);
|
||||
// Global if the user asked, or as a safe fallback when we don't yet know the wallet
|
||||
// (pre-connect) — better a visible-everywhere contact than one orphaned to no wallet.
|
||||
entry.scope = (s_edit_global || activeHash.empty()) ? std::string("global") : activeHash;
|
||||
if (isEdit) {
|
||||
if (app->addressBook().updateEntry(s_selected_index, entry)) {
|
||||
Notifications::instance().success(TR("address_book_updated"));
|
||||
@@ -242,7 +256,12 @@ void RenderContactsTab(App* app)
|
||||
std::vector<size_t> visibleRows;
|
||||
visibleRows.reserve(book.size());
|
||||
for (size_t i = 0; i < book.size(); ++i) {
|
||||
if (matchesSearch(book.entries()[i], needle)) visibleRows.push_back(i);
|
||||
const auto& e = book.entries()[i];
|
||||
if (!matchesSearch(e, needle)) continue;
|
||||
// Scope filter: global contacts + this wallet's contacts. Pre-connect (activeHash empty)
|
||||
// we don't know the wallet yet, so show everything rather than hide contacts.
|
||||
if (!activeHash.empty() && !e.visibleInWallet(activeHash)) continue;
|
||||
visibleRows.push_back(i);
|
||||
}
|
||||
|
||||
// Address list — size the table to fill the tab, leaving room for the count footer.
|
||||
@@ -328,6 +347,13 @@ void RenderContactsTab(App* app)
|
||||
if (ImGui::IsItemHovered()) {
|
||||
material::Tooltip("%s", entry.address.c_str());
|
||||
}
|
||||
// Globe badge marks a global contact (shown in every wallet).
|
||||
if (entry.isGlobal()) {
|
||||
ImGui::SameLine(0.0f, 8.0f);
|
||||
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()),
|
||||
"%s", ICON_MD_PUBLIC);
|
||||
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_badge_tt"));
|
||||
}
|
||||
|
||||
ImGui::TableNextColumn();
|
||||
ImGui::TextUnformatted(entry.notes.c_str());
|
||||
|
||||
@@ -1060,6 +1060,9 @@ void I18n::loadBuiltinEnglish()
|
||||
// --- Address Book Dialog ---
|
||||
strings_["address_book_add"] = "Add Address";
|
||||
strings_["address_book_add_new"] = "Add New";
|
||||
strings_["contact_global"] = "Show in every wallet (global contact)";
|
||||
strings_["contact_global_tt"] = "On: this contact stays visible no matter which wallet you load. Off: it belongs to the current wallet only.";
|
||||
strings_["contact_global_badge_tt"] = "Global contact — visible in every wallet";
|
||||
strings_["address_book_added"] = "Address added to book";
|
||||
strings_["address_book_count"] = "%zu addresses saved";
|
||||
strings_["address_book_deleted"] = "Entry deleted";
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
#include "chat/chat_database.h"
|
||||
#include "daemon/daemon_controller.h"
|
||||
#include "data/transaction_history_cache.h"
|
||||
#include "data/address_book.h"
|
||||
#include "data/wallet_index.h"
|
||||
#include "daemon/lifecycle_adapters.h"
|
||||
#include "data/wallet_state.h"
|
||||
#include "rpc/connection.h"
|
||||
@@ -51,6 +53,7 @@
|
||||
#include <cmath>
|
||||
#include <thread>
|
||||
#include <atomic>
|
||||
#include <cstdlib>
|
||||
#include <deque>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
@@ -6029,6 +6032,140 @@ void testHushChatShuffledReceive()
|
||||
|
||||
} // namespace
|
||||
|
||||
// Per-wallet address-book scoping (multi-wallet P1): global vs wallet-scoped contacts, the
|
||||
// legacy-entry migration, and scope-aware de-duplication.
|
||||
void testAddressBookScope()
|
||||
{
|
||||
using dragonx::data::AddressBook;
|
||||
using dragonx::data::AddressBookEntry;
|
||||
|
||||
// --- Pure visibility logic (drives the contacts-tab filter) ---
|
||||
AddressBookEntry g("Alice", "zs1alice", "", "global");
|
||||
AddressBookEntry wA("Bob", "zs1bob", "", "hashA");
|
||||
AddressBookEntry legacy; legacy.scope = ""; // legacy entry (no scope) => global
|
||||
EXPECT_TRUE(g.isGlobal());
|
||||
EXPECT_TRUE(!wA.isGlobal());
|
||||
EXPECT_TRUE(legacy.isGlobal());
|
||||
EXPECT_TRUE(g.visibleInWallet("hashA")); // global shows everywhere
|
||||
EXPECT_TRUE(g.visibleInWallet("hashB"));
|
||||
EXPECT_TRUE(wA.visibleInWallet("hashA")); // wallet-scoped shows only in its wallet
|
||||
EXPECT_TRUE(!wA.visibleInWallet("hashB"));
|
||||
EXPECT_TRUE(legacy.visibleInWallet("anything"));
|
||||
|
||||
// --- Round-trip + migration + scope-aware dedup, isolated under a temp $HOME ---
|
||||
const char* oldHome = std::getenv("HOME");
|
||||
std::string savedHome = oldHome ? oldHome : "";
|
||||
fs::path tmp = fs::temp_directory_path() /
|
||||
("obsidian_ab_test_" + std::to_string((long long)::time(nullptr)));
|
||||
fs::create_directories(tmp);
|
||||
setenv("HOME", tmp.string().c_str(), 1);
|
||||
|
||||
// A pre-scoping addressbook.json (no "scope" field) migrates to global on load.
|
||||
fs::path cfg = tmp / ".config" / "ObsidianDragon";
|
||||
fs::create_directories(cfg);
|
||||
std::ofstream(cfg / "addressbook.json")
|
||||
<< R"({"entries":[{"label":"Legacy","address":"zs1legacy","notes":""}]})";
|
||||
{
|
||||
AddressBook book;
|
||||
EXPECT_TRUE(book.load());
|
||||
EXPECT_EQ(book.size(), (size_t)1);
|
||||
EXPECT_TRUE(book.entries()[0].isGlobal()); // migrated -> global
|
||||
}
|
||||
|
||||
// Same address may be a contact in two DIFFERENT wallets, but not twice in one; and a global
|
||||
// entry collides with any wallet-scoped one for the same address (they'd show together).
|
||||
{
|
||||
AddressBook book;
|
||||
book.load(); // has the migrated global "Legacy"
|
||||
EXPECT_TRUE(book.addEntry(AddressBookEntry("BobA", "zs1bob", "", "walletA")));
|
||||
EXPECT_TRUE(book.addEntry(AddressBookEntry("BobB", "zs1bob", "", "walletB"))); // diff wallet: ok
|
||||
EXPECT_TRUE(!book.addEntry(AddressBookEntry("BobA2", "zs1bob", "", "walletA"))); // same wallet: dup
|
||||
EXPECT_TRUE(!book.addEntry(AddressBookEntry("BobG", "zs1bob", "", "global"))); // global collides
|
||||
EXPECT_TRUE(!book.hasVisibleDuplicate("zs1novel", "walletA")); // novel address: ok
|
||||
}
|
||||
|
||||
// Scope survives save + reload.
|
||||
{
|
||||
AddressBook reopened;
|
||||
reopened.load();
|
||||
int ia = -1, ib = -1;
|
||||
for (size_t i = 0; i < reopened.size(); ++i) {
|
||||
const auto& e = reopened.entries()[i];
|
||||
if (e.address == "zs1bob" && e.scope == "walletA") ia = (int)i;
|
||||
if (e.address == "zs1bob" && e.scope == "walletB") ib = (int)i;
|
||||
}
|
||||
EXPECT_TRUE(ia >= 0); // both wallet-scoped Bobs round-tripped
|
||||
EXPECT_TRUE(ib >= 0);
|
||||
}
|
||||
|
||||
if (!savedHome.empty()) setenv("HOME", savedHome.c_str(), 1); else unsetenv("HOME");
|
||||
std::error_code ec; fs::remove_all(tmp, ec);
|
||||
}
|
||||
|
||||
// Wallet-files metadata index (multi-wallet P1b): upsert change-detection, find, extra folders,
|
||||
// and save/reload round-trip.
|
||||
void testWalletIndex()
|
||||
{
|
||||
using dragonx::data::WalletIndex;
|
||||
using dragonx::data::WalletIndexEntry;
|
||||
|
||||
// upsert change-detection + find + folders (in-memory)
|
||||
{
|
||||
WalletIndex idx;
|
||||
WalletIndexEntry a; a.fileName = "wallet.dat"; a.cachedBalance = 1.5; a.cachedAddressCount = 3;
|
||||
EXPECT_TRUE(idx.upsert(a)); // new -> changed
|
||||
EXPECT_TRUE(!idx.upsert(a)); // identical -> no change (skips save)
|
||||
a.cachedBalance = 2.0;
|
||||
EXPECT_TRUE(idx.upsert(a)); // value changed -> changed
|
||||
EXPECT_TRUE(idx.find("wallet.dat") != nullptr);
|
||||
EXPECT_NEAR(idx.find("wallet.dat")->cachedBalance, 2.0, 1e-9);
|
||||
EXPECT_TRUE(idx.find("missing.dat") == nullptr);
|
||||
WalletIndexEntry b; b.fileName = "wallet-2.dat";
|
||||
idx.upsert(b);
|
||||
EXPECT_EQ(idx.entries().size(), (size_t)2);
|
||||
EXPECT_TRUE(idx.addExtraFolder("/some/dir"));
|
||||
EXPECT_TRUE(!idx.addExtraFolder("/some/dir")); // duplicate rejected
|
||||
EXPECT_TRUE(idx.removeExtraFolder("/some/dir"));
|
||||
EXPECT_TRUE(!idx.removeExtraFolder("/some/dir")); // already gone
|
||||
}
|
||||
|
||||
// save + reload round-trip under a temp $HOME
|
||||
{
|
||||
const char* oldHome = std::getenv("HOME");
|
||||
std::string savedHome = oldHome ? oldHome : "";
|
||||
fs::path tmp = fs::temp_directory_path() /
|
||||
("obsidian_wi_test_" + std::to_string((long long)::time(nullptr)));
|
||||
fs::create_directories(tmp);
|
||||
setenv("HOME", tmp.string().c_str(), 1);
|
||||
|
||||
{
|
||||
WalletIndex idx;
|
||||
idx.load();
|
||||
WalletIndexEntry a; a.fileName = "wallet.dat"; a.displayName = "Main";
|
||||
a.cachedBalance = 12.34; a.cachedAddressCount = 7; a.walletIdentityHash = "hashX";
|
||||
a.lastOpenedEpoch = 1720000000; a.sizeBytesAtLastOpen = 98304; a.syncedHere = true;
|
||||
idx.upsert(a);
|
||||
idx.addExtraFolder("/other/wallets");
|
||||
EXPECT_TRUE(idx.save());
|
||||
}
|
||||
{
|
||||
WalletIndex idx;
|
||||
EXPECT_TRUE(idx.load());
|
||||
const auto* w = idx.find("wallet.dat");
|
||||
EXPECT_TRUE(w != nullptr);
|
||||
EXPECT_EQ(w->displayName, std::string("Main"));
|
||||
EXPECT_EQ(w->cachedAddressCount, (long long)7);
|
||||
EXPECT_EQ(w->walletIdentityHash, std::string("hashX"));
|
||||
EXPECT_TRUE(w->syncedHere);
|
||||
EXPECT_NEAR(w->cachedBalance, 12.34, 1e-9);
|
||||
EXPECT_EQ(idx.extraFolders().size(), (size_t)1);
|
||||
}
|
||||
|
||||
if (!savedHome.empty()) setenv("HOME", savedHome.c_str(), 1); else unsetenv("HOME");
|
||||
std::error_code ec2; fs::remove_all(tmp, ec2);
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
testConnectionConfig();
|
||||
@@ -6063,6 +6200,8 @@ int main()
|
||||
testExplorerBlockCache();
|
||||
testTransactionHistoryCache();
|
||||
testTransactionHistoryCachePrunesOldWallets();
|
||||
testAddressBookScope();
|
||||
testWalletIndex();
|
||||
testTransactionHistoryCacheRefreshApply();
|
||||
testLiteBridgeOwnedStringCopiesBeforeFreeOnSuccess();
|
||||
testLiteBridgeOwnedStringClassifiesNullWithoutFree();
|
||||
|
||||
Reference in New Issue
Block a user