From 8255923f331dd6c5af7f796657ecb8c3cca19714 Mon Sep 17 00:00:00 2001 From: DanS Date: Mon, 12 Oct 2026 21:42:23 -0500 Subject: [PATCH] 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 --- src/app.cpp | 3 ++ src/app.h | 10 +++++ src/app_network.cpp | 21 +++++++++++ src/config/settings.cpp | 20 ++++++++++ src/config/settings.h | 25 +++++++++++-- src/ui/windows/balance_components.cpp | 8 ++-- src/ui/windows/market_tab.cpp | 51 +++++++++++++------------- tests/test_phase4.cpp | 53 +++++++++++++++++++++++++++ 8 files changed, 159 insertions(+), 32 deletions(-) diff --git a/src/app.cpp b/src/app.cpp index 7ab136d..355a5de 100644 --- a/src/app.cpp +++ b/src/app.cpp @@ -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. diff --git a/src/app.h b/src/app.h index 7f1535a..0bff61b 100644 --- a/src/app.h +++ b/src/app.h @@ -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_{}; diff --git a/src/app_network.cpp b/src/app_network.cpp index c195bff..0306728 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -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 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; diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 602ed43..615774d 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -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& 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()) { diff --git a/src/config/settings.h b/src/config/settings.h index 41f8f11..7de1d1c 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -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& getPortfolioEntries() const { return portfolio_entries_; } void setPortfolioEntries(const std::vector& 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& walletAddresses, bool singleWallet); // Lite servers the user has hidden from the Network tab (kept by URL, shown via a toggle). const std::set& getLiteHiddenServers() const { return lite_hidden_servers_; } diff --git a/src/ui/windows/balance_components.cpp b/src/ui/windows/balance_components.cpp index 7ce407d..142e42a 100644 --- a/src/ui/windows/balance_components.cpp +++ b/src/ui/windows/balance_components.cpp @@ -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 diff --git a/src/ui/windows/market_tab.cpp b/src/ui/windows/market_tab.cpp index 08457ec..3d0cb95 100644 --- a/src/ui/windows/market_tab.cpp +++ b/src/ui/windows/market_tab.cpp @@ -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 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 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 diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index e1819e3..0b02b03 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -7640,6 +7640,58 @@ void testPortfolioHelpers() EXPECT_EQ(group[0], std::string("R-t2")); } +void testPortfolioScopeVisibility() +{ + using E = dragonx::config::Settings::PortfolioEntry; + E global; // scope "" + E stable; stable.scope = "w:aaaa"; + E legacy; legacy.scope = std::string(64, 'a'); // an old address-set hash + + EXPECT_TRUE(global.visibleInWallet("w:aaaa")); + EXPECT_TRUE(global.visibleInWallet("")); // global shows even before the wallet resolves + EXPECT_TRUE(stable.visibleInWallet("w:aaaa")); + EXPECT_FALSE(stable.visibleInWallet("w:bbbb")); // another wallet's group + EXPECT_FALSE(stable.visibleInWallet("")); // wallet unresolved: hidden + EXPECT_TRUE(legacy.visibleInWallet("w:aaaa")); // fail open until migrated + EXPECT_FALSE(legacy.visibleInWallet("")); + EXPECT_TRUE(legacy.isLegacyScope()); + EXPECT_FALSE(stable.isLegacyScope()); + EXPECT_FALSE(global.isLegacyScope()); + + // Migration rules. + dragonx::config::Settings s; + E byHash; byHash.label = "byHash"; byHash.scope = "H1"; + E byAddr; byAddr.label = "byAddr"; byAddr.scope = "H0"; byAddr.addresses = {"zs-mine", "zs-other"}; + E foreign; foreign.label = "foreign"; foreign.scope = "H9"; foreign.addresses = {"zs-theirs"}; + E already; already.label = "already"; already.scope = "w:keep"; + E glob; glob.label = "glob"; + s.setPortfolioEntries({byHash, byAddr, foreign, already, glob}); + const std::set mine = {"zs-mine", "zs-x"}; + + EXPECT_EQ(s.reattachLegacyPortfolioScopes("", "H1", mine, false), 0); // no id -> no-op + EXPECT_EQ(s.reattachLegacyPortfolioScopes("w:me", "H1", mine, false), 2); // hash match + address overlap + const auto& e = s.getPortfolioEntries(); + EXPECT_EQ(e[0].scope, std::string("w:me")); + EXPECT_EQ(e[1].scope, std::string("w:me")); + EXPECT_EQ(e[2].scope, std::string("H9")); // ambiguous: left legacy (still visible, fail open) + EXPECT_EQ(e[3].scope, std::string("w:keep")); // stable ids are never touched + EXPECT_EQ(e[4].scope, std::string("")); // global stays global + EXPECT_EQ(s.reattachLegacyPortfolioScopes("w:me", "H1", mine, false), 0); // idempotent + EXPECT_EQ(s.reattachLegacyPortfolioScopes("w:me", "", {}, true), 1); // single wallet claims the rest + EXPECT_EQ(s.getPortfolioEntries()[2].scope, std::string("w:me")); + + // Round trip: the stable scope survives save/load and an address-set change cannot orphan it. + const fs::path dir = fs::temp_directory_path() / "dragonx_portfolio_scope_test"; + fs::create_directories(dir); + const std::string p = (dir / "settings.json").string(); + EXPECT_TRUE(s.save(p)); + dragonx::config::Settings s2; + EXPECT_TRUE(s2.load(p)); + EXPECT_EQ(s2.getPortfolioEntries().size(), static_cast(5)); + EXPECT_EQ(s2.getPortfolioEntries()[0].scope, std::string("w:me")); + std::error_code ec; fs::remove_all(dir, ec); +} + // Market price-series math (data/market_series.h) — minute-resampling + time-window bucketing // that feed the price chart and the portfolio-group sparklines. void testMarketSeries() @@ -17864,6 +17916,7 @@ int main() testConsoleFlatVisibleIndices(); testConsoleModel(); testPortfolioHelpers(); + testPortfolioScopeVisibility(); testMarketSeries(); testExchangeCandles(); testWalletStateAddressListRebuild();