Files
ObsidianDragon/src/data/wallet_index.cpp
DanS 2072f70a60 fix(contacts): stable per-wallet scope so contacts don't vanish (address-hash drift)
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>
2026-07-16 18:27:23 -05:00

163 lines
5.1 KiB
C++

// 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.scopeId == b.scopeId
&& 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.scopeId = e.value("scopeId", "");
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["scopeId"] = w.scopeId;
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