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
+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_; }