fix(wallet-switch): cluster C — no mis-scoping in the unknown-identity window

From the audit: while a switch/first-load is in flight the wallet identity hash
is empty, so contact/portfolio scope-writes silently fell back to "global"
(leaking a wallet-specific entry into every wallet) and scope-filters showed
every wallet's scoped entries.

- Contacts: a NEW wallet-scoped contact created while the identity is unknown is
  now refused with a clear message (tick global or wait); editing an existing
  scoped contact preserves its scope instead of demoting it to global.
- Portfolio: the "Add group" button is disabled while the identity is unknown
  (with a tooltip), so a new group can't get an empty/global scope.
- Both views' visibility filters now show ONLY global entries when the identity
  is unknown — never another wallet's scoped contacts/groups — instead of
  showing everything.
- +2 i18n strings (8 langs; reworded one zh string to stay within the CJK subset).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-15 12:45:18 -05:00
parent fc509a9340
commit 122db9d903
11 changed files with 57 additions and 13 deletions

View File

@@ -763,10 +763,25 @@ void RenderContactsTab(App* app)
};
data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes);
entry.avatar = s_edit_avatar;
// 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) {
// Scope: global if the user asked; otherwise this wallet — but ONLY when we actually
// know which wallet is loaded. While the identity is unknown (the switch / first-load
// window) don't silently make a wallet-scoped contact global: preserve an edited
// contact's existing scope, and refuse a NEW wallet-scoped one (tick global or wait).
bool blocked = false;
if (s_edit_global) {
entry.scope = "global";
} else if (!activeHash.empty()) {
entry.scope = activeHash;
} else if (isEdit && s_selected_index >= 0 && s_selected_index < (int)book.size()) {
entry.scope = book.entries()[s_selected_index].scope; // keep; don't demote to global
if (entry.scope.empty()) entry.scope = "global";
} else {
Notifications::instance().error(TR("contact_wallet_loading"));
blocked = true; // don't create a wallet-scoped contact with an unknown wallet
}
if (blocked) {
// leave the dialog open so the user can tick global or retry once loaded
} else if (isEdit) {
if (app->addressBook().updateEntry(s_selected_index, entry)) {
Notifications::instance().success(TR("address_book_updated"));
s_show_edit_dialog = false;
@@ -895,9 +910,9 @@ 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. 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;
// 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;
visibleRows.push_back(i);
}

View File

@@ -380,14 +380,14 @@ static bool pfIndexVisible(App* app, int i)
const auto& es = app->settings()->getPortfolioEntries();
if (i < 0 || i >= (int)es.size()) return false;
const std::string h = app->activeWalletIdentityHash();
return es[i].scope.empty() || h.empty() || es[i].scope == h;
return es[i].scope.empty() || (!h.empty() && es[i].scope == h);
}
static int pfFirstVisibleIndex(App* app)
{
const auto& es = app->settings()->getPortfolioEntries();
const std::string h = app->activeWalletIdentityHash();
for (int i = 0; i < (int)es.size(); i++)
if (es[i].scope.empty() || h.empty() || es[i].scope == h) return i;
if (es[i].scope.empty() || (!h.empty() && es[i].scope == h)) return i;
return -1;
}
@@ -823,7 +823,9 @@ static void RenderPortfolioEditor(App* app)
const std::string activeHash = app->activeWalletIdentityHash();
std::vector<int> vis;
for (int i = 0; i < (int)entries.size(); i++)
if (entries[i].scope.empty() || activeHash.empty() || entries[i].scope == activeHash)
// Global groups always; this wallet's groups only when the identity is known — never
// another wallet's scoped groups during the switch/first-load window.
if (entries[i].scope.empty() || (!activeHash.empty() && entries[i].scope == activeHash))
vis.push_back(i);
if (vis.empty())
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_entries"));
@@ -905,7 +907,11 @@ static void RenderPortfolioEditor(App* app)
}
ImGui::EndChild();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// Add immediately creates a persisted "Untitled" group and selects it for editing.
// Add immediately creates a persisted "Untitled" group scoped to this wallet — so it's disabled
// while the wallet identity is unknown (switch / first-load window), otherwise a new group would
// get an empty scope and show under every wallet.
const bool pfIdentityKnown = !app->activeWalletIdentityHash().empty();
ImGui::BeginDisabled(!pfIdentityKnown);
if (material::TactileButton(TR("portfolio_add_entry"), ImVec2(masterW, addH))) {
// Auto-save the current group, then start a fresh one selected for editing.
pfCommitIfNeeded(app);
@@ -918,6 +924,9 @@ static void RenderPortfolioEditor(App* app)
s_pfEdit.sel = (int)es.size() - 1;
PortfolioBeginEdit(app, s_pfEdit.sel);
}
ImGui::EndDisabled();
if (!pfIdentityKnown && ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenDisabled))
material::Tooltip("%s", TR("portfolio_wallet_loading"));
}
ImGui::EndGroup();
@@ -1967,7 +1976,9 @@ static void mktDrawPortfolio(const MktCtx& cx)
std::vector<int> vis;
for (int i = 0; i < (int)allEntries.size(); i++) {
const auto& e = allEntries[i];
if (e.scope.empty() || activeHash.empty() || e.scope == activeHash) vis.push_back(i);
// Global groups always; this wallet's only when the identity is known (never another
// wallet's scoped groups during the switch/first-load window).
if (e.scope.empty() || (!activeHash.empty() && e.scope == activeHash)) vis.push_back(i);
}
int style = app->settings() ? app->settings()->getPortfolioStyle() : 0;
@@ -2110,7 +2121,7 @@ void RenderMarketTab(App* app)
const std::string pfActiveHash = app->activeWalletIdentityHash();
int pfVisN = 0;
for (const auto& e : pfEntriesGeo)
if (e.scope.empty() || pfActiveHash.empty() || e.scope == pfActiveHash) pfVisN++;
if (e.scope.empty() || (!pfActiveHash.empty() && e.scope == pfActiveHash)) pfVisN++;
int pfStyle = app->settings()->getPortfolioStyle();
float pfRowH = (pfStyle == 0 ? 46.0f : pfStyle == 1 ? 64.0f : 84.0f) * mktDp;
float pfRowGap = Layout::spacingSm();