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
parent 82e61da62f
commit 72bf59149d
13 changed files with 524 additions and 14 deletions

View File

@@ -31,6 +31,7 @@ static int s_confirm_delete_idx = -1; // armed storage index; a 2nd Delete co
static char s_edit_label[128] = "";
static char s_edit_address[512] = "";
static char s_edit_notes[512] = "";
static bool s_edit_global = false; // add/edit dialog: "visible in every wallet" toggle
static char s_search[128] = "";
static void copyEditField(char* dest, size_t destSize, const std::string& source) {
@@ -69,16 +70,22 @@ 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();
auto clearEditFields = []() {
s_edit_label[0] = '\0';
s_edit_address[0] = '\0';
s_edit_notes[0] = '\0';
s_edit_global = false; // new contacts default to the current wallet
};
auto loadEditFields = [](const data::AddressBookEntry& entry) {
copyEditField(s_edit_label, sizeof(s_edit_label), entry.label);
copyEditField(s_edit_address, sizeof(s_edit_address), entry.address);
copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes);
s_edit_global = entry.isGlobal();
};
bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast<int>(book.size());
@@ -149,6 +156,10 @@ void RenderContactsTab(App* app)
material::LabeledInputMultiline(TR("notes_optional"), isEdit ? "##EditNotes" : "##AddNotes",
s_edit_notes, sizeof(s_edit_notes), ImVec2(formW, notesH));
ImGui::Spacing();
ImGui::Checkbox(TR("contact_global"), &s_edit_global);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_tt"));
bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0;
float totalActionsW = actionW * 2.0f + actionGap;
material::BeginOverlayDialogFooter(totalActionsW);
@@ -164,6 +175,9 @@ void RenderContactsTab(App* app)
return s;
};
data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes);
// Global if the user asked, or as a safe fallback when we don't yet know the wallet
// (pre-connect) — better a visible-everywhere contact than one orphaned to no wallet.
entry.scope = (s_edit_global || activeHash.empty()) ? std::string("global") : activeHash;
if (isEdit) {
if (app->addressBook().updateEntry(s_selected_index, entry)) {
Notifications::instance().success(TR("address_book_updated"));
@@ -242,7 +256,12 @@ void RenderContactsTab(App* app)
std::vector<size_t> visibleRows;
visibleRows.reserve(book.size());
for (size_t i = 0; i < book.size(); ++i) {
if (matchesSearch(book.entries()[i], needle)) visibleRows.push_back(i);
const auto& e = book.entries()[i];
if (!matchesSearch(e, needle)) continue;
// Scope filter: global contacts + this wallet's contacts. Pre-connect (activeHash empty)
// we don't know the wallet yet, so show everything rather than hide contacts.
if (!activeHash.empty() && !e.visibleInWallet(activeHash)) continue;
visibleRows.push_back(i);
}
// Address list — size the table to fill the tab, leaving room for the count footer.
@@ -328,6 +347,13 @@ void RenderContactsTab(App* app)
if (ImGui::IsItemHovered()) {
material::Tooltip("%s", entry.address.c_str());
}
// Globe badge marks a global contact (shown in every wallet).
if (entry.isGlobal()) {
ImGui::SameLine(0.0f, 8.0f);
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(material::OnSurfaceMedium()),
"%s", ICON_MD_PUBLIC);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_badge_tt"));
}
ImGui::TableNextColumn();
ImGui::TextUnformatted(entry.notes.c_str());