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:
@@ -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.
|
||||
|
||||
@@ -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_{};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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_; }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<std::string> 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<size_t>(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();
|
||||
|
||||
Reference in New Issue
Block a user