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:
10
src/app.h
10
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_; }
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user