From ecc004355914105e8898db8dce68bfc300672568 Mon Sep 17 00:00:00 2001 From: DanS Date: Wed, 1 Jul 2026 22:32:53 -0500 Subject: [PATCH] feat(portfolio): persisted portfolio entries + pure sum helpers Foundation for the Market tab's configurable portfolio (custom labels tied to groups of addresses): - Settings gains a PortfolioEntry {label, addresses[]} list, persisted in settings.json as a JSON array-of-objects (same mechanism as lite_servers_), with get/setPortfolioEntries(). - data/portfolio.h (header-only, ImGui-free): SumPortfolioBalance() sums a group's DRGX balance against the live per-address list (unknown addresses contribute 0), plus PortfolioEntryContains/Add/Remove group helpers. - Unit-tested (testPortfolioHelpers): empty/known/unknown-address sums, and the add/remove/contains membership semantics. UI (editor dialog, Market card rework, Overview right-click) comes next. Full-node + lite build clean; ctest green. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/config/settings.cpp | 21 ++++++++++++++ src/config/settings.h | 13 +++++++++ src/data/portfolio.h | 61 +++++++++++++++++++++++++++++++++++++++++ tests/test_phase4.cpp | 42 ++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 src/data/portfolio.h diff --git a/src/config/settings.cpp b/src/config/settings.cpp index 1003b92..a4a9fca 100644 --- a/src/config/settings.cpp +++ b/src/config/settings.cpp @@ -294,6 +294,19 @@ bool Settings::load(const std::string& path) for (const auto& w : j["saved_pool_workers"]) if (w.is_string()) saved_pool_workers_.push_back(w.get()); } + if (j.contains("portfolio_entries") && j["portfolio_entries"].is_array()) { + portfolio_entries_.clear(); + for (const auto& e : j["portfolio_entries"]) { + if (!e.is_object()) continue; + PortfolioEntry entry; + if (e.contains("label") && e["label"].is_string()) + entry.label = e["label"].get(); + if (e.contains("addresses") && e["addresses"].is_array()) + for (const auto& a : e["addresses"]) + if (a.is_string()) entry.addresses.push_back(a.get()); + if (!entry.label.empty()) portfolio_entries_.push_back(std::move(entry)); + } + } if (j.contains("font_scale") && j["font_scale"].is_number()) font_scale_ = std::max(1.0f, std::min(1.5f, j["font_scale"].get())); if (j.contains("window_width") && j["window_width"].is_number_integer()) @@ -437,6 +450,14 @@ bool Settings::save(const std::string& path) j["saved_pool_workers"] = json::array(); for (const auto& w : saved_pool_workers_) j["saved_pool_workers"].push_back(w); + j["portfolio_entries"] = json::array(); + for (const auto& e : portfolio_entries_) { + json entry; + entry["label"] = e.label; + entry["addresses"] = json::array(); + for (const auto& a : e.addresses) entry["addresses"].push_back(a); + j["portfolio_entries"].push_back(std::move(entry)); + } j["font_scale"] = font_scale_; j["settings_version"] = std::string(DRAGONX_VERSION); if (window_width_ > 0 && window_height_ > 0) { diff --git a/src/config/settings.h b/src/config/settings.h index 40f41e9..8a67369 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -73,6 +73,13 @@ public: bool enabled = true; }; + // A user-defined portfolio entry: a custom label tied to a group of wallet addresses. + // The Market tab's portfolio card sums these addresses' balances under the label. + struct PortfolioEntry { + std::string label; + std::vector addresses; + }; + // Theme std::string getTheme() const { return theme_; } void setTheme(const std::string& theme) { theme_ = theme; } @@ -251,6 +258,10 @@ public: const std::vector& getLiteServers() const { return lite_servers_; } void setLiteServers(const std::vector& servers) { lite_servers_ = servers; } + // User-defined portfolio entries (Market tab). "All funds" is implicit, not stored here. + const std::vector& getPortfolioEntries() const { return portfolio_entries_; } + void setPortfolioEntries(const std::vector& entries) { portfolio_entries_ = entries; } + // Lite servers the user has hidden from the Network tab (kept by URL, shown via a toggle). const std::set& getLiteHiddenServers() const { return lite_hidden_servers_; } bool isLiteServerHidden(const std::string& url) const { return lite_hidden_servers_.count(url) > 0; } @@ -432,6 +443,8 @@ private: }; std::set lite_hidden_servers_; // server URLs hidden from the Network tab + std::vector portfolio_entries_; // Market tab custom portfolio groups + bool verbose_logging_ = false; std::set debug_categories_; bool theme_effects_enabled_ = true; diff --git a/src/data/portfolio.h b/src/data/portfolio.h new file mode 100644 index 0000000..300fc3c --- /dev/null +++ b/src/data/portfolio.h @@ -0,0 +1,61 @@ +// DragonX Wallet - ImGui Edition +// Copyright 2024-2026 The Hush Developers +// Released under the GPLv3 +// +// Portfolio helpers — pure balance math for the Market tab's configurable portfolio. +// A portfolio entry is a user label tied to a group of wallet addresses (persisted in +// Settings as PortfolioEntry); these helpers sum/query that group against the live +// per-address balance list. No I/O, no ImGui — unit-testable. + +#pragma once + +#include "wallet_state.h" // AddressInfo + +#include +#include +#include + +namespace dragonx { +namespace data { + +// Sum the DRGX balance of `entryAddresses` by looking them up in the wallet's per-address +// list. Addresses not present in the wallet contribute 0 (e.g. removed/rescanned). Pure. +inline double SumPortfolioBalance(const std::vector& entryAddresses, + const std::vector& walletAddresses) +{ + if (entryAddresses.empty()) return 0.0; + std::unordered_set want(entryAddresses.begin(), entryAddresses.end()); + double sum = 0.0; + for (const auto& a : walletAddresses) + if (want.count(a.address)) sum += a.balance; + return sum; +} + +// Whether `address` is part of the entry's address group. +inline bool PortfolioEntryContains(const std::vector& entryAddresses, + const std::string& address) +{ + for (const auto& a : entryAddresses) + if (a == address) return true; + return false; +} + +// Add `address` to the group if absent (returns true if it was added). +inline bool PortfolioEntryAdd(std::vector& entryAddresses, const std::string& address) +{ + if (address.empty() || PortfolioEntryContains(entryAddresses, address)) return false; + entryAddresses.push_back(address); + return true; +} + +// Remove `address` from the group if present (returns true if it was removed). +inline bool PortfolioEntryRemove(std::vector& entryAddresses, const std::string& address) +{ + for (auto it = entryAddresses.begin(); it != entryAddresses.end(); ++it) { + if (*it == address) { entryAddresses.erase(it); return true; } + } + return false; +} + +} // namespace data +} // namespace dragonx diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 5efa322..aa17a4c 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -39,6 +39,7 @@ #include "wallet/lite_wallet_state_mapper.h" #include "config/settings.h" #include "data/wallet_state.h" +#include "data/portfolio.h" #include "fake_lite_backend.h" #include @@ -2581,6 +2582,46 @@ void testConsoleModel() } } +// Portfolio helpers — sum/query a labeled address group against per-address balances. +void testPortfolioHelpers() +{ + using dragonx::data::AddressInfo; + using dragonx::data::SumPortfolioBalance; + using dragonx::data::PortfolioEntryContains; + using dragonx::data::PortfolioEntryAdd; + using dragonx::data::PortfolioEntryRemove; + + std::vector wallet; + auto mk = [](const char* addr, double bal, const char* type) { + AddressInfo a; a.address = addr; a.balance = bal; a.type = type; return a; + }; + wallet.push_back(mk("R-t1", 1.5, "transparent")); + wallet.push_back(mk("R-t2", 2.0, "transparent")); + wallet.push_back(mk("zs-s1", 10.0, "shielded")); + wallet.push_back(mk("zs-s2", 0.25, "shielded")); + + // Empty group sums to 0. + EXPECT_NEAR(SumPortfolioBalance({}, wallet), 0.0, 1e-9); + // A group of two known addresses. + EXPECT_NEAR(SumPortfolioBalance({"R-t1", "zs-s1"}, wallet), 11.5, 1e-9); + // Unknown addresses contribute 0 (not present in the wallet). + EXPECT_NEAR(SumPortfolioBalance({"R-t2", "does-not-exist"}, wallet), 2.0, 1e-9); + // All shielded. + EXPECT_NEAR(SumPortfolioBalance({"zs-s1", "zs-s2"}, wallet), 10.25, 1e-9); + + // Membership + add/remove semantics. + std::vector group = {"R-t1"}; + EXPECT_TRUE(PortfolioEntryContains(group, "R-t1")); + EXPECT_FALSE(PortfolioEntryContains(group, "R-t2")); + EXPECT_TRUE(PortfolioEntryAdd(group, "R-t2")); // added + EXPECT_FALSE(PortfolioEntryAdd(group, "R-t2")); // already present -> no-op + EXPECT_EQ(group.size(), static_cast(2)); + EXPECT_TRUE(PortfolioEntryRemove(group, "R-t1")); // removed + EXPECT_FALSE(PortfolioEntryRemove(group, "R-t1"));// gone -> no-op + EXPECT_EQ(group.size(), static_cast(1)); + EXPECT_EQ(group[0], std::string("R-t2")); +} + // ComputeConsoleFoldSpans — brace/bracket matching over pretty-printed JSON lines. void testConsoleFoldSpans() { @@ -5311,6 +5352,7 @@ int main() testDaemonLifecycleAdapters(); testConsoleTextLayout(); testConsoleModel(); + testPortfolioHelpers(); testConsoleFoldSpans(); testConsoleScrollController(); testConsoleSelectionController();