fix(settings): scope portfolio groups by wallet identity

Market-tab portfolio groups were tied to a wallet by a hash of its
address set. That hash changed whenever an address was added, so a
group built for a wallet could silently drop out of view once the
wallet gained a new address.

- Groups are now scoped by the wallet's stable id, which is persisted
  and never derived from the address set, so adding an address can no
  longer orphan a group.
- An older build's address-hash scope fails open (the group stays
  visible) and is migrated once per wallet per session to the stable id.
  A legacy group is claimed when its old hash matches, when one of its
  own addresses belongs to this wallet, or when only one wallet is
  known; anything ambiguous is left legacy and stays visible.
- Scope filtering waits until the wallet's addresses are loaded, so a
  group never flashes an empty 0.0000 balance and Add stays disabled
  until the wallet is resolved. This works on Lite, which never stamps
  an address-update time.

Behaviour: an older build opened after this change hides groups tagged
with a stable "w:" scope (they are not deleted and return on the next
upgrade) - worth a release note.

Tests: group visibility across global, stable and legacy scopes with the
wallet resolved and not; the migration rules (hash match, address
overlap, single-wallet claim, ambiguous left alone, idempotent); and a
save/load round trip where an address-set change cannot orphan the scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 21:42:23 -05:00
co-authored by Claude Opus 4.8
parent c3ffc97b31
commit 8255923f33
8 changed files with 159 additions and 32 deletions
+26 -25
View File
@@ -371,30 +371,30 @@ static void pfCommitIfNeeded(App* app)
if (existing) { base = entries[s_pfEdit.sel]; if (pfWorkingMatches(base)) return; }
auto e = pfBuildWorking(base);
if (e.scope.empty()) {
const std::string h = app->activeWalletIdentityHash();
if (!h.empty()) e.scope = h;
const std::string sid = app->portfolioScopeId();
if (!sid.empty()) e.scope = sid;
}
if (existing) entries[s_pfEdit.sel] = e;
else { entries.push_back(e); s_pfEdit.sel = (int)entries.size() - 1; }
pfPersist(settings, entries);
}
// A group is visible to the active wallet when it carries the wallet's scope, is unscoped (legacy —
// shown everywhere), or the wallet identity isn't resolved yet. The editor mirrors the Market summary's
// scope filter so it only lists/edits the current wallet's groups.
// A group is visible to the active wallet when it carries the wallet's stable scope id, is unscoped (global,
// shown everywhere), or still carries a legacy address-hash scope (fail-open until migrated). Nothing but
// global groups is visible while the wallet id is unresolved. The editor mirrors the Market summary's filter
// so it only lists/edits the current wallet's groups.
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].visibleInWallet(app->portfolioScopeId());
}
static int pfFirstVisibleIndex(App* app)
{
const auto& es = app->settings()->getPortfolioEntries();
const std::string h = app->activeWalletIdentityHash();
const std::string sid = app->portfolioScopeId();
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].visibleInWallet(sid)) return i;
return -1;
}
@@ -831,12 +831,12 @@ static void RenderPortfolioEditor(App* app)
ImDrawList* mdl = ImGui::GetWindowDrawList();
// Only list groups scoped to the active wallet (or legacy/unscoped) — same filter the
// Market summary uses. `i` stays the storage index (selection/delete operate on storage).
const std::string activeHash = app->activeWalletIdentityHash();
const std::string scopeId = app->portfolioScopeId();
std::vector<int> vis;
for (int i = 0; i < (int)entries.size(); i++)
// 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))
// Global groups always; this wallet's (and not-yet-migrated legacy) groups only once the wallet
// is resolved - never another wallet's scoped groups during the switch/first-load window.
if (entries[i].visibleInWallet(scopeId))
vis.push_back(i);
if (vis.empty())
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("portfolio_no_entries"));
@@ -935,10 +935,10 @@ static void RenderPortfolioEditor(App* app)
}
ImGui::EndChild();
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// 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
// Add immediately creates a persisted "Untitled" group scoped to this wallet - so it's disabled
// while the wallet is unresolved (switch / first-load window), otherwise a new group would
// get an empty scope and show under every wallet.
const bool pfIdentityKnown = !app->activeWalletIdentityHash().empty();
const bool pfIdentityKnown = !app->portfolioScopeId().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.
@@ -946,7 +946,7 @@ static void RenderPortfolioEditor(App* app)
auto es = settings->getPortfolioEntries();
config::Settings::PortfolioEntry ne;
ne.label = TR("portfolio_untitled");
ne.scope = app->activeWalletIdentityHash(); // new group defaults to this wallet
ne.scope = app->portfolioScopeId(); // new group defaults to this wallet (stable id)
es.push_back(ne);
pfPersist(settings, es);
s_pfEdit.sel = (int)es.size() - 1;
@@ -2113,14 +2113,15 @@ static void mktDrawPortfolio(const MktCtx& cx)
// arrows switch styles (handled in RenderMarketTab). Click a row to edit it. ----
{
const auto& allEntries = app->settings()->getPortfolioEntries();
const std::string activeHash = app->activeWalletIdentityHash();
// Visible = this wallet's groups + legacy (empty scope). Keep storage indices for edit targets.
const std::string scopeId = app->portfolioScopeId();
// Visible = this wallet's groups + global (empty scope) + not-yet-migrated legacy. Keep storage indices
// for edit targets.
std::vector<int> vis;
for (int i = 0; i < (int)allEntries.size(); i++) {
const auto& e = allEntries[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);
// Global groups always; this wallet's only once the wallet is resolved (never another wallet's
// scoped groups during the switch/first-load window).
if (e.visibleInWallet(scopeId)) vis.push_back(i);
}
int style = app->settings() ? app->settings()->getPortfolioStyle() : 0;
@@ -2315,11 +2316,11 @@ void RenderMarketTab(App* app)
+ Layout::spacingMd(); // gap before the group cards
// Content height = full-width rows in the selected style, confined to a bounded area (up to
// pfMaxVisibleRows) with an internal scroll when the visible groups overflow. Only groups
// visible in the active wallet (own scope or legacy/global) count toward the height.
const std::string pfActiveHash = app->activeWalletIdentityHash();
// visible in the active wallet (own scope, global or not-yet-migrated legacy) count toward the height.
const std::string pfScopeId = app->portfolioScopeId();
int pfVisN = 0;
for (const auto& e : pfEntriesGeo)
if (e.scope.empty() || (!pfActiveHash.empty() && e.scope == pfActiveHash)) pfVisN++;
if (e.visibleInWallet(pfScopeId)) pfVisN++;
int pfStyle = app->settings()->getPortfolioStyle();
float pfRowH = pfRowHeight(pfStyle, mktDp);
float pfRowGap = pfRowGapFor(pfStyle, mktDp); // Table rows abut