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
+3
View File
@@ -1085,6 +1085,9 @@ void App::update()
state_.rebuildAddressList();
}
// Move legacy (address-hash) portfolio scopes to the stable wallet id once this wallet's addresses are in.
reattachLegacyPortfolioScopes();
using RefreshTimer = services::NetworkRefreshService::Timer;
// Wipe a secret (seed/private key) from the clipboard once its auto-clear delay elapses.
+10
View File
@@ -343,6 +343,15 @@ public:
// except during a full UI sweep (capture_mode_), where it returns the existing id or "".
std::string activeWalletScopeId();
// Stable scope for the Market-tab portfolio groups: activeWalletScopeId() once this wallet's addresses are
// loaded, else "" (so a group never shows a 0.0000 balance from an empty list, and Add stays disabled
// until the wallet is known - the same window the old identity-hash gate covered). Never the address hash.
// In a full UI sweep (capture_mode_) a fixed demo id so the sweep can still exercise Add.
std::string portfolioScopeId();
// One-time (per wallet per session) migration of legacy hash-scoped portfolio groups to the stable id.
// Called from update(); a no-op until the wallet's addresses are loaded.
void reattachLegacyPortfolioScopes();
data::WalletIndex& walletIndex() { return wallet_index_; }
const data::WalletIndex& walletIndex() const { return wallet_index_; }
@@ -1792,6 +1801,7 @@ private:
// Set when a bootstrap completes; consumed once the daemon is connected to auto-run a rescan
// that reconciles the preserved wallet.dat against the freshly-imported chain.
bool post_bootstrap_rescan_pending_ = false;
std::string portfolio_scope_migrated_for_; // scope id the legacy-portfolio-scope migration last ran for
bool post_bootstrap_probe_in_flight_ = false; // its detectLowestAvailableBlockHeight() is on worker_
int post_bootstrap_probe_attempts_ = 0; // probes that could not tell; gives up after 6
std::chrono::steady_clock::time_point post_bootstrap_probe_retry_at_{};
+21
View File
@@ -1520,6 +1520,27 @@ std::string App::activeWalletScopeId()
return id;
}
std::string App::portfolioScopeId()
{
if (capture_mode_) return "w:sweep-demo"; // in-memory only: Settings/WalletIndex saves are latched in a sweep
if (state_.z_addresses.empty() && state_.t_addresses.empty()) return std::string(); // not loaded yet
return activeWalletScopeId();
}
void App::reattachLegacyPortfolioScopes()
{
if (!settings_ || capture_mode_ || !state_.connected) return;
const std::string scopeId = portfolioScopeId();
if (scopeId.empty() || scopeId == portfolio_scope_migrated_for_) return;
portfolio_scope_migrated_for_ = scopeId; // once per wallet per session (re-runs after a wallet switch)
std::set<std::string> mine;
for (const auto& a : state_.z_addresses) if (!a.address.empty()) mine.insert(a.address);
for (const auto& a : state_.t_addresses) if (!a.address.empty()) mine.insert(a.address);
const int moved = settings_->reattachLegacyPortfolioScopes(
scopeId, activeWalletIdentityHash(), mine, wallet_index_.entries().size() <= 1);
if (moved > 0) settings_->save(); // UI thread, once
}
void App::updateWalletIndexForActiveWallet(bool markOpened)
{
if (!settings_) return;
+20
View File
@@ -464,6 +464,26 @@ bool Settings::load(const std::string& path)
}
}
int Settings::reattachLegacyPortfolioScopes(const std::string& scopeId, const std::string& identityHash,
const std::set<std::string>& walletAddresses, bool singleWallet)
{
if (scopeId.empty()) return 0;
int moved = 0;
for (auto& e : portfolio_entries_) {
if (!e.isLegacyScope()) continue; // global stays global; "w:" ids are already stable
bool mine = singleWallet || (!identityHash.empty() && e.scope == identityHash);
if (!mine) {
for (const auto& a : e.addresses) {
if (walletAddresses.count(a)) { mine = true; break; }
}
}
if (!mine) continue; // ambiguous: leave legacy (fail-open visible)
e.scope = scopeId;
++moved;
}
return moved;
}
bool Settings::save()
{
if (settings_path_.empty()) {
+22 -3
View File
@@ -100,10 +100,21 @@ public:
bool showSparkline = false; // show a price-trend sparkline (live-market bases only)
int sparklineInterval = 4; // 0=minute 1=hour 2=day 3=week 4=month (default month: a real curve
// from the daily series, vs the young in-session minute buffer)
// Per-wallet visibility: "" (shown in every wallet — legacy/global) or a wallet-identity
// hash (shown only when that wallet is active). New entries are tagged with the current
// wallet so a portfolio built for wallet A doesn't clutter wallet B.
// Per-wallet visibility: "" (shown in every wallet - legacy/global) or the active wallet's STABLE scope
// id ("w:"+hex, App::activeWalletScopeId(), persisted in wallets.json, never derived from the address
// set). Older builds stored the address-set identity hash here, which drifted whenever an address was
// added; such a legacy scope fails open (stays visible) until reattachLegacyPortfolioScopes() moves it
// to the stable id. New entries are tagged with the current wallet so a portfolio built for wallet A
// doesn't clutter wallet B.
std::string scope;
bool isGlobal() const { return scope.empty(); }
bool isLegacyScope() const { return !scope.empty() && scope.rfind("w:", 0) != 0; }
bool visibleInWallet(const std::string& scopeId) const {
if (isGlobal()) return true;
if (scopeId.empty()) return false; // wallet not resolved yet: never another wallet's groups
if (isLegacyScope()) return true; // fail open until migrated
return scope == scopeId;
}
// Legacy dashboard-grid placement (deprecated by the row layout; kept for back-compat so an
// older build's saved positions aren't dropped, but no longer used by the renderer).
int gridCol = -1;
@@ -463,6 +474,14 @@ public:
// User-defined portfolio entries (Market tab). "All funds" is implicit, not stored here.
const std::vector<PortfolioEntry>& getPortfolioEntries() const { return portfolio_entries_; }
void setPortfolioEntries(const std::vector<PortfolioEntry>& entries) { portfolio_entries_ = entries; }
// Re-attach groups stuck on a legacy (address-set-hash) scope to the stable wallet id `scopeId`.
// A legacy group is claimed when ANY of: its scope == identityHash (the current address-set hash, exact
// match); at least one of its own addresses is in `walletAddresses` (groups carry their addresses, so
// this attributes correctly in multi-wallet installs); or `singleWallet` (only one known wallet, so
// attribution is unambiguous - same rule contacts use). Anything else stays legacy (fail-open visible).
// Idempotent. Does NOT save: the caller saves once if the return value is > 0. Returns #entries moved.
int reattachLegacyPortfolioScopes(const std::string& scopeId, const std::string& identityHash,
const std::set<std::string>& walletAddresses, bool singleWallet);
// Lite servers the user has hidden from the Network tab (kept by URL, shown via a toggle).
const std::set<std::string>& getLiteHiddenServers() const { return lite_hidden_servers_; }
+4 -4
View File
@@ -724,12 +724,12 @@ void RenderSharedAddressList(App* app, float listH, float availW,
ImGui::Separator();
if (ImGui::BeginMenu(TR("portfolio_add_to"))) {
const auto& pf = app->settings()->getPortfolioEntries();
const std::string activeHash = app->activeWalletIdentityHash();
const std::string scopeId = app->portfolioScopeId();
int shown = 0;
for (int pi = 0; pi < (int)pf.size(); pi++) {
// Only this wallet's groups (or legacy/unscoped), matching the Market summary —
// don't offer to add this address to another wallet's group.
if (!(pf[pi].scope.empty() || activeHash.empty() || pf[pi].scope == activeHash))
// Only this wallet's groups (or global / not-yet-migrated legacy), matching the Market
// summary - don't offer to add this address to another wallet's group.
if (!pf[pi].visibleInWallet(scopeId))
continue;
// PushID keeps the item ID unique even when two groups share a label (a
// label-derived MenuItem ID would collide); a blank label shows a placeholder
+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