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
+53
View File
@@ -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();