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:
@@ -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