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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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());

View File

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

View File

@@ -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();