feat(wallet): per-wallet data isolation foundation (P1)

First phase of multi-wallet support: keep each wallet's data separate so loading
a different wallet no longer shows the previous one's data, and lay the metadata
groundwork for the wallet-files list.

- Address book scoped per wallet: AddressBookEntry gains a "scope" ("global" or a
  wallet-identity hash); the Contacts tab shows global + current-wallet contacts
  (App::activeWalletIdentityHash) with a "show in every wallet" toggle + globe
  badge. Legacy entries migrate to "global". Scope-aware de-dup allows the same
  address across different wallets.
- Wallet metadata index (data/wallet_index -> wallets.json): file-keyed cache of
  balance / address count / identity / size / last-opened / synced-here, since
  those can't be read off a wallet.dat without loading it. Populated on connect +
  address refresh (change-detecting upsert). Plus an active_wallet_file setting
  for the -wallet=<name> switch coming in P2.

Unit-tested (testAddressBookScope, testWalletIndex): migration, visibility filter,
scope-aware de-dup, upsert change-detection, save/reload round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 15:57:23 -05:00
co-authored by Claude Opus 4.8
parent 82e61da62f
commit 72bf59149d
13 changed files with 524 additions and 14 deletions
+26 -8
View File
@@ -51,7 +51,10 @@ bool AddressBook::load()
e.label = entry.value("label", "");
e.address = entry.value("address", "");
e.notes = entry.value("notes", "");
// Legacy entries (no "scope") migrate to "global" so nothing disappears when
// multi-wallet scoping lands — a contact you already had stays visible everywhere.
e.scope = entry.value("scope", "global");
if (!e.address.empty()) {
entries_.push_back(e);
}
@@ -82,6 +85,7 @@ bool AddressBook::save()
e["label"] = entry.label;
e["address"] = entry.address;
e["notes"] = entry.notes;
e["scope"] = entry.scope.empty() ? std::string("global") : entry.scope;
j["entries"].push_back(e);
}
@@ -103,11 +107,12 @@ bool AddressBook::save()
bool AddressBook::addEntry(const AddressBookEntry& entry)
{
// Check for duplicate address
if (findByAddress(entry.address) >= 0) {
// Reject a duplicate only within the same visible set (same wallet or global) — the same
// address may legitimately be a contact in two different wallets.
if (hasVisibleDuplicate(entry.address, entry.scope)) {
return false;
}
entries_.push_back(entry);
return save();
}
@@ -118,12 +123,11 @@ bool AddressBook::updateEntry(size_t index, const AddressBookEntry& entry)
return false;
}
// Check for duplicate address (excluding current entry)
int existing = findByAddress(entry.address);
if (existing >= 0 && static_cast<size_t>(existing) != index) {
// Check for a duplicate visible alongside this entry's scope (excluding the entry being edited)
if (hasVisibleDuplicate(entry.address, entry.scope, static_cast<int>(index))) {
return false;
}
entries_[index] = entry;
return save();
}
@@ -148,5 +152,19 @@ int AddressBook::findByAddress(const std::string& address) const
return -1;
}
bool AddressBook::hasVisibleDuplicate(const std::string& address, const std::string& scope,
int excludeIndex) const
{
AddressBookEntry probe; probe.scope = scope; // reuse the isGlobal()/scope logic
for (size_t i = 0; i < entries_.size(); i++) {
if (static_cast<int>(i) == excludeIndex) continue;
const auto& e = entries_[i];
if (e.address != address) continue;
// Collides if they'd ever be shown together: same wallet scope, or either is global.
if (e.isGlobal() || probe.isGlobal() || e.scope == scope) return true;
}
return false;
}
} // namespace data
} // namespace dragonx