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
+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()) {