From 2072f70a600606d4a56db8707862376b890a48a7 Mon Sep 17 00:00:00 2001 From: DanS Date: Thu, 16 Jul 2026 18:27:23 -0500 Subject: [PATCH] fix(contacts): stable per-wallet scope so contacts don't vanish (address-hash drift) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/app.h | 10 ++++++++-- src/app_network.cpp | 28 ++++++++++++++++++++++++++++ src/data/address_book.cpp | 14 ++++++++++++++ src/data/address_book.h | 9 +++++++++ src/data/wallet_index.cpp | 3 +++ src/data/wallet_index.h | 2 ++ src/ui/windows/contacts_tab.cpp | 25 +++++++++++++++++++------ 7 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/app.h b/src/app.h index dbbdb6c..89ce2b1 100644 --- a/src/app.h +++ b/src/app.h @@ -201,10 +201,16 @@ 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). + // Hash of the active wallet's identity (derived from its address list). This is the tx-history + // cache key — it changes when the address set changes, so DON'T scope persistent user data on it. std::string activeWalletIdentityHash() const; + // Stable per-wallet id ("w:"+hex) for scoping persistent per-wallet data (address-book contacts). + // Generated once and persisted in the wallet index (keyed by wallet file), never recomputed from + // the mutable address set — so creating addresses / locking / disconnecting never changes it. + // Empty only when there is no active wallet file. Establishes + persists the id on first call. + std::string activeWalletScopeId(); + data::WalletIndex& walletIndex() { return wallet_index_; } const data::WalletIndex& walletIndex() const { return wallet_index_; } diff --git a/src/app_network.cpp b/src/app_network.cpp index 837b1f1..85ac2c7 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -1004,6 +1004,34 @@ std::string App::activeWalletIdentityHash() const return data::TransactionHistoryCache::walletIdentityHash(identity); } +std::string App::activeWalletScopeId() +{ + if (!settings_) return std::string(); + const std::string file = settings_->getActiveWalletFile(); + if (file.empty()) return std::string(); + + // Reuse the persisted id if we have one for this wallet file. + if (const auto* existing = wallet_index_.find(file)) { + if (!existing->scopeId.empty()) return existing->scopeId; + } + + // Establish one now: 16 random bytes, "w:"-prefixed so it's distinguishable from the legacy + // 64-hex address-hash scopes we migrate away from. + unsigned char rnd[16]; + randombytes_buf(rnd, sizeof(rnd)); + static const char* kHex = "0123456789abcdef"; + std::string id = "w:"; + for (unsigned char b : rnd) { id.push_back(kHex[b >> 4]); id.push_back(kHex[b & 0x0F]); } + + data::WalletIndexEntry e; + if (const auto* existing = wallet_index_.find(file)) e = *existing; + e.fileName = file; + if (e.displayName.empty()) e.displayName = file; + e.scopeId = id; + if (wallet_index_.upsert(e)) wallet_index_.save(); + return id; +} + void App::updateWalletIndexForActiveWallet(bool markOpened) { if (!settings_) return; diff --git a/src/data/address_book.cpp b/src/data/address_book.cpp index b386632..ef2d2ea 100644 --- a/src/data/address_book.cpp +++ b/src/data/address_book.cpp @@ -144,6 +144,20 @@ bool AddressBook::removeEntry(size_t 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++) { diff --git a/src/data/address_book.h b/src/data/address_book.h index 498a19a..1c4ae02 100644 --- a/src/data/address_book.h +++ b/src/data/address_book.h @@ -86,6 +86,15 @@ public: */ bool removeEntry(size_t index); + /** + * @brief Re-attach contacts stuck on a legacy (drifting address-hash) scope to a stable wallet id. + * Rewrites every non-global entry whose scope is NOT already a stable "w:"-prefixed id to + * `scopeId`, and saves once if anything changed. Recovery for contacts orphaned when the + * old address-set-hash scope shifted (e.g. after creating a new address). + * @return number of entries re-scoped. + */ + int reattachLegacyScopes(const std::string& scopeId); + /** * @brief Find entry by address (any scope). Used for contact-label lookups. * @param address Address to search for diff --git a/src/data/wallet_index.cpp b/src/data/wallet_index.cpp index f3aea91..9250de3 100644 --- a/src/data/wallet_index.cpp +++ b/src/data/wallet_index.cpp @@ -25,6 +25,7 @@ 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 @@ -61,6 +62,7 @@ bool WalletIndex::load() 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); @@ -96,6 +98,7 @@ bool WalletIndex::save() 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; diff --git a/src/data/wallet_index.h b/src/data/wallet_index.h index 3e67fd5..434c198 100644 --- a/src/data/wallet_index.h +++ b/src/data/wallet_index.h @@ -22,6 +22,8 @@ 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 + std::string scopeId; // stable per-wallet id ("w:"+hex) for scoping contacts etc.; + // generated once, never recomputed from the (mutable) address set 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 diff --git a/src/ui/windows/contacts_tab.cpp b/src/ui/windows/contacts_tab.cpp index bc66796..e791ab5 100644 --- a/src/ui/windows/contacts_tab.cpp +++ b/src/ui/windows/contacts_tab.cpp @@ -277,9 +277,16 @@ 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(); + // Stable per-wallet id that scopes which contacts show + tags newly-added ones. Unlike the old + // address-hash identity it doesn't drift when the address set changes, and it's known even before + // connect. Empty only when there's no active wallet file. + const std::string activeHash = app->activeWalletScopeId(); + // One-time recovery: contacts saved under the old drifting address-hash scope were orphaned when the + // wallet's address set changed. When there's a single known wallet (so attribution is unambiguous), + // re-attach those legacy-scoped contacts to this wallet's stable id. Idempotent (converted contacts + // become "w:"-scoped), so it does real work only once. + if (!activeHash.empty() && app->walletIndex().entries().size() <= 1) + app->addressBook().reattachLegacyScopes(activeHash); auto clearEditFields = []() { s_edit_label[0] = '\0'; @@ -910,9 +917,15 @@ void RenderContactsTab(App* app) for (size_t i = 0; i < book.size(); ++i) { const auto& e = book.entries()[i]; if (!matchesSearch(e, needle)) continue; - // Scope filter: global contacts + this wallet's contacts. When the wallet identity is unknown - // (switch / first-load window), show only GLOBAL contacts — never another wallet's scoped ones. - if (activeHash.empty() ? !e.isGlobal() : !e.visibleInWallet(activeHash)) continue; + // Scope filter: show global + this wallet's contacts. A legacy (non-"w:") scope belongs to a + // wallet whose old address-hash identity has drifted and can't be reattributed here (multi-wallet + // case where recovery didn't run) — fail OPEN so those contacts are never hidden. Stable "w:" + // scopes match strictly. When no wallet is active, show global + legacy only. + bool visible; + if (e.isGlobal()) visible = true; + else if (e.scope.rfind("w:", 0) == 0) visible = !activeHash.empty() && e.scope == activeHash; + else visible = true; // legacy scope → fail open (recovery) + if (!visible) continue; visibleRows.push_back(i); }