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>
This commit is contained in:
2026-07-16 18:27:23 -05:00
co-authored by Claude Opus 4.8
parent ee9ea15233
commit 2072f70a60
7 changed files with 83 additions and 8 deletions
+14
View File
@@ -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++) {
+9
View File
@@ -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
+3
View File
@@ -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;
+2
View File
@@ -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