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
parent ee9ea15233
commit 2072f70a60
7 changed files with 83 additions and 8 deletions

View File

@@ -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);
}