Contacts were scoped by activeWalletIdentityHash() — a hash of the wallet's
ENTIRE address set. Creating a new receive address grows the set, changing the
hash, so every contact stamped with the old hash falls out of the scope filter
(contacts_tab.cpp:915) while still being counted — the "3 saved, 1 showing"
symptom, where only the one set to global (which bypasses the scope) survives.
It also hid scoped contacts on every startup before the daemon connected (hash
empty until addresses load).
Introduce a stable per-wallet scope id: WalletIndexEntry.scopeId ("w:"+random
hex), generated once and persisted in the wallet index (keyed by wallet file),
never recomputed from the mutable address set — so creating addresses, locking,
or disconnecting never changes it. App::activeWalletScopeId() establishes it on
first use. Contacts now scope + filter on this instead of the drifting hash. The
tx-history-cache identity (the hash's real purpose) is untouched.
Recovery for already-orphaned contacts:
- AddressBook::reattachLegacyScopes() re-attaches non-global, non-"w:" contacts
to the active wallet's stable id; run once when there's a single known wallet
(unambiguous attribution). Idempotent.
- The scope filter fails OPEN for legacy scopes (multi-wallet case where recovery
can't attribute them) so no contact is ever hidden; stable "w:" scopes still
match strictly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
187 lines
5.6 KiB
C++
187 lines
5.6 KiB
C++
// DragonX Wallet - ImGui Edition
|
|
// Copyright 2024-2026 The Hush Developers
|
|
// Released under the GPLv3
|
|
|
|
#include "address_book.h"
|
|
|
|
#include <nlohmann/json.hpp>
|
|
#include <fstream>
|
|
#include <filesystem>
|
|
|
|
#include "../util/logger.h"
|
|
#include "../util/platform.h"
|
|
|
|
namespace fs = std::filesystem;
|
|
using json = nlohmann::json;
|
|
|
|
namespace dragonx {
|
|
namespace data {
|
|
|
|
AddressBook::AddressBook() = default;
|
|
AddressBook::~AddressBook() = default;
|
|
|
|
std::string AddressBook::getDefaultPath()
|
|
{
|
|
// Co-locate with settings.json in the per-variant config dir (Lite -> ObsidianDragonLite/).
|
|
// util::Platform::getConfigDir() owns the per-platform + per-variant path in one place.
|
|
const std::string dir = util::Platform::getConfigDir();
|
|
fs::create_directories(dir);
|
|
return (fs::path(dir) / "addressbook.json").string();
|
|
}
|
|
|
|
bool AddressBook::load()
|
|
{
|
|
file_path_ = getDefaultPath();
|
|
|
|
std::ifstream file(file_path_);
|
|
if (!file.is_open()) {
|
|
// No file yet - that's OK
|
|
return true;
|
|
}
|
|
|
|
try {
|
|
json j;
|
|
file >> j;
|
|
|
|
entries_.clear();
|
|
|
|
if (j.contains("entries") && j["entries"].is_array()) {
|
|
for (const auto& entry : j["entries"]) {
|
|
AddressBookEntry e;
|
|
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");
|
|
e.avatar = entry.value("avatar", "");
|
|
|
|
if (!e.address.empty()) {
|
|
entries_.push_back(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
DEBUG_LOGF("Address book loaded: %zu entries\n", entries_.size());
|
|
return true;
|
|
|
|
} catch (const std::exception& e) {
|
|
DEBUG_LOGF("Error loading address book: %s\n", e.what());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool AddressBook::save()
|
|
{
|
|
if (file_path_.empty()) {
|
|
file_path_ = getDefaultPath();
|
|
}
|
|
|
|
try {
|
|
json j;
|
|
j["entries"] = json::array();
|
|
|
|
for (const auto& entry : entries_) {
|
|
json e;
|
|
e["label"] = entry.label;
|
|
e["address"] = entry.address;
|
|
e["notes"] = entry.notes;
|
|
e["scope"] = entry.scope.empty() ? std::string("global") : entry.scope;
|
|
if (!entry.avatar.empty()) e["avatar"] = entry.avatar;
|
|
j["entries"].push_back(e);
|
|
}
|
|
|
|
// Atomic + durable: temp file + fsync + rename, so a crash mid-write can't
|
|
// truncate addressbook.json (which is fully rewritten on every entry change).
|
|
// Owner-only (0600) — it holds the user's saved contacts.
|
|
if (!util::Platform::writeFileAtomically(file_path_, j.dump(2), /*restrictPermissions=*/true)) {
|
|
DEBUG_LOGF("Could not write address book: %s\n", file_path_.c_str());
|
|
return false;
|
|
}
|
|
DEBUG_LOGF("Address book saved: %zu entries\n", entries_.size());
|
|
return true;
|
|
|
|
} catch (const std::exception& e) {
|
|
DEBUG_LOGF("Error saving address book: %s\n", e.what());
|
|
return false;
|
|
}
|
|
}
|
|
|
|
bool AddressBook::addEntry(const AddressBookEntry& entry)
|
|
{
|
|
// 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();
|
|
}
|
|
|
|
bool AddressBook::updateEntry(size_t index, const AddressBookEntry& entry)
|
|
{
|
|
if (index >= entries_.size()) {
|
|
return false;
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
bool AddressBook::removeEntry(size_t index)
|
|
{
|
|
if (index >= entries_.size()) {
|
|
return false;
|
|
}
|
|
|
|
entries_.erase(entries_.begin() + index);
|
|
return save();
|
|
}
|
|
|
|
int AddressBook::reattachLegacyScopes(const std::string& scopeId)
|
|
{
|
|
if (scopeId.empty()) return 0;
|
|
int rescoped = 0;
|
|
for (auto& e : entries_) {
|
|
if (e.isGlobal()) continue; // global stays global
|
|
if (e.scope.rfind("w:", 0) == 0) continue; // already a stable scope
|
|
e.scope = scopeId;
|
|
++rescoped;
|
|
}
|
|
if (rescoped > 0) save();
|
|
return rescoped;
|
|
}
|
|
|
|
int AddressBook::findByAddress(const std::string& address) const
|
|
{
|
|
for (size_t i = 0; i < entries_.size(); i++) {
|
|
if (entries_[i].address == address) {
|
|
return static_cast<int>(i);
|
|
}
|
|
}
|
|
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
|