diff --git a/CMakeLists.txt b/CMakeLists.txt index dee5255..d1e23ac 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/src/app.cpp b/src/app.cpp index cc146c3..19624c5 100644 --- a/src/app.cpp +++ b/src/app.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. diff --git a/src/app.h b/src/app.h index 0894941..f3db173 100644 --- a/src/app.h +++ b/src/app.h @@ -16,6 +16,7 @@ #include #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 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; diff --git a/src/app_network.cpp b/src/app_network.cpp index 0bc8a35..c5f28a3 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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(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(sz); + } + + if (markOpened) { + e.lastOpenedEpoch = static_cast(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; diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 2245cc6..8e4fd7c 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -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_; diff --git a/src/config/settings.h b/src/config/settings.h index 57c1848..6c6a8c1 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -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= (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= the daemon loads (multi-wallet) bool seed_migration_pending_ = false; std::string seed_migration_dest_; std::string seed_migration_temp_dir_; diff --git a/src/data/address_book.cpp b/src/data/address_book.cpp index 1b06a20..2246c1c 100644 --- a/src/data/address_book.cpp +++ b/src/data/address_book.cpp @@ -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(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(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(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 diff --git a/src/data/address_book.h b/src/data/address_book.h index 6ac7d19..01e5cdf 100644 --- a/src/data/address_book.h +++ b/src/data/address_book.h @@ -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 */ diff --git a/src/data/wallet_index.cpp b/src/data/wallet_index.cpp new file mode 100644 index 0000000..f3aea91 --- /dev/null +++ b/src/data/wallet_index.cpp @@ -0,0 +1,159 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#include "wallet_index.h" + +#include +#include +#include +#include + +#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().empty()) + extra_folders_.push_back(d.get()); + } + } + 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 diff --git a/src/data/wallet_index.h b/src/data/wallet_index.h new file mode 100644 index 0000000..3e67fd5 --- /dev/null +++ b/src/data/wallet_index.h @@ -0,0 +1,68 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 + +#pragma once + +#include +#include + +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& entries() const { return entries_; } + const std::vector& 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 entries_; + std::vector extra_folders_; + std::string file_path_; +}; + +} // namespace data +} // namespace dragonx diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp index 74e102f..af8227d 100644 --- a/src/ui/windows/contacts_tab.cpp +++ b/src/ui/windows/contacts_tab.cpp @@ -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(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 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()); diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index b4fd442..29487fc 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -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"; diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index d431811..0a309bc 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -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 #include #include +#include #include #include #include @@ -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();