diff --git a/src/app.h b/src/app.h index 363cec6..198e39c 100644 --- a/src/app.h +++ b/src/app.h @@ -285,6 +285,9 @@ public: void refreshMiningInfo(); void refreshPeerInfo(); void refreshMarketData(); + // Fetch the live exchange/pair list from CoinGecko once per session (venues are + // near-static); populates state.market.exchanges. Safe to call every frame. + void refreshExchanges(); /// @brief Per-category refresh intervals, adjusted by active tab using RefreshIntervals = services::NetworkRefreshService::Intervals; @@ -516,6 +519,7 @@ private: long long last_balance_eval_ms_ = 0; // steady-clock ms of the last refresh kick bool balance_refresh_pending_ = false; // UI asked for an immediate refresh bool balance_snapshot_seen_ = false; // the current in-flight snapshot was applied + bool exchanges_fetch_started_ = false; // once-per-session CoinGecko tickers fetch util::AsyncTaskManager async_tasks_; bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog diff --git a/src/app_network.cpp b/src/app_network.cpp index 8da98aa..204f3b0 100644 --- a/src/app_network.cpp +++ b/src/app_network.cpp @@ -1631,6 +1631,51 @@ void App::refreshPrice() void App::refreshMarketData() { refreshPrice(); + refreshExchanges(); +} + +void App::refreshExchanges() +{ + if (!settings_ || !settings_->getFetchPrices()) return; + if (!worker_) return; + if (exchanges_fetch_started_) return; // once per session (venues are near-static) + exchanges_fetch_started_ = true; + + worker_->post([this]() -> rpc::RPCWorker::MainCb { + std::vector exchanges; + try { + CURL* curl = curl_easy_init(); + if (curl) { + std::string body; + const char* url = "https://api.coingecko.com/api/v3/coins/dragonx-2/tickers"; + auto write_callback = [](void* contents, size_t size, size_t nmemb, std::string* userp) -> size_t { + size_t total = size * nmemb; + userp->append((char*)contents, total); + return total; + }; + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +write_callback); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 12L); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "DragonX-Wallet/1.0"); + CURLcode res = curl_easy_perform(curl); + long http_code = 0; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + curl_easy_cleanup(curl); + if (res == CURLE_OK && http_code >= 200 && http_code < 300) + exchanges = data::parseCoinGeckoTickers(body); + } + } catch (...) {} + + return [this, exchanges = std::move(exchanges)]() mutable { + if (!exchanges.empty()) + state_.market.exchanges = std::move(exchanges); + else + exchanges_fetch_started_ = false; // allow a retry if it failed/empty + }; + }); } // ============================================================================ diff --git a/src/config/settings.h b/src/config/settings.h index 013a759..40f41e9 100644 --- a/src/config/settings.h +++ b/src/config/settings.h @@ -437,8 +437,8 @@ private: bool theme_effects_enabled_ = true; bool low_spec_mode_ = false; bool reduce_motion_ = false; - std::string selected_exchange_ = "TradeOgre"; - std::string selected_pair_ = "DRGX/BTC"; + std::string selected_exchange_ = "Nonkyc.io"; + std::string selected_pair_ = "DRGX/USDT"; // Pool mining std::string pool_url_ = "pool.dragonx.is:3433"; diff --git a/src/data/exchange_info.cpp b/src/data/exchange_info.cpp index 0bc35d3..668b754 100644 --- a/src/data/exchange_info.cpp +++ b/src/data/exchange_info.cpp @@ -4,11 +4,19 @@ #include "exchange_info.h" +#include + +#include +#include + namespace dragonx { namespace data { const std::vector& getExchangeRegistry() { + // Offline seed / fallback. The live list from CoinGecko (parseCoinGeckoTickers) + // supersedes this when available; keep the known venues here so the tab is never + // empty without network. static const std::vector registry = { { "Nonkyc.io", @@ -17,9 +25,68 @@ const std::vector& getExchangeRegistry() {"DRGX", "USDT", "DRGX/USDT", "https://nonkyc.io/market/DRGX_USDT"}, } }, + { + "OurBit", + "https://www.ourbit.com", + { + {"DRGX", "USDT", "DRGX/USDT", "https://www.ourbit.com/exchange/DRGX_USDT"}, + } + }, }; return registry; } +namespace { +// Extract the scheme://host origin from a URL (for ExchangeInfo.baseUrl). +std::string originOf(const std::string& url) +{ + const auto scheme = url.find("://"); + if (scheme == std::string::npos) return url; + const auto host = url.find('/', scheme + 3); + return (host == std::string::npos) ? url : url.substr(0, host); +} +} // namespace + +std::vector parseCoinGeckoTickers(const std::string& body) +{ + std::vector exchanges; + try { + const nlohmann::json j = nlohmann::json::parse(body); + if (!j.contains("tickers") || !j["tickers"].is_array()) return {}; + + std::unordered_map byName; // exchange name -> index + for (const auto& t : j["tickers"]) { + if (!t.is_object()) continue; + const std::string base = t.value("base", std::string{}); + const std::string target = t.value("target", std::string{}); + std::string market; + if (t.contains("market") && t["market"].is_object()) + market = t["market"].value("name", std::string{}); + const std::string tradeUrl = t.value("trade_url", std::string{}); + if (base.empty() || target.empty() || market.empty()) continue; + + auto it = byName.find(market); + if (it == byName.end()) { + byName[market] = exchanges.size(); + exchanges.push_back(ExchangeInfo{market, originOf(tradeUrl), {}}); + it = byName.find(market); + } + ExchangePair pair{base, target, base + "/" + target, tradeUrl}; + // Skip duplicate pairs on the same exchange. + bool dup = false; + for (const auto& p : exchanges[it->second].pairs) + if (p.displayName == pair.displayName) { dup = true; break; } + if (!dup) exchanges[it->second].pairs.push_back(std::move(pair)); + } + } catch (...) { + return {}; + } + // Drop exchanges that ended up with no pairs (defensive). + exchanges.erase(std::remove_if(exchanges.begin(), exchanges.end(), + [](const ExchangeInfo& e) { return e.pairs.empty(); }), + exchanges.end()); + return exchanges; +} + } // namespace data } // namespace dragonx diff --git a/src/data/exchange_info.h b/src/data/exchange_info.h index add5c81..587c689 100644 --- a/src/data/exchange_info.h +++ b/src/data/exchange_info.h @@ -30,9 +30,19 @@ struct ExchangeInfo { }; /** - * @brief Returns the static registry of supported exchanges + pairs + * @brief Returns the static registry of supported exchanges + pairs. + * Used as the offline fallback / seed when the live CoinGecko list is unavailable. */ const std::vector& getExchangeRegistry(); +/** + * @brief Parse a CoinGecko /coins/{id}/tickers JSON body into the exchange registry. + * + * Groups tickers by exchange (market.name), preserving first-seen order, into + * ExchangeInfo entries (each pair carries base/target/displayName/trade_url). Returns + * an empty vector on parse failure or when no usable tickers are present. Pure (no I/O). + */ +std::vector parseCoinGeckoTickers(const std::string& json); + } // namespace data } // namespace dragonx diff --git a/src/data/wallet_state.h b/src/data/wallet_state.h index 221dc64..1a51b81 100644 --- a/src/data/wallet_state.h +++ b/src/data/wallet_state.h @@ -9,6 +9,8 @@ #include #include +#include "exchange_info.h" + namespace dragonx { /** @@ -160,6 +162,10 @@ struct MarketInfo { // Price history for chart std::vector price_history; static constexpr int MAX_HISTORY = 24; // 24 hours + + // Exchanges/pairs fetched live from CoinGecko (empty until fetched; the Market tab + // falls back to data::getExchangeRegistry() while empty). + std::vector exchanges; }; /** diff --git a/src/ui/windows/market_tab.cpp b/src/ui/windows/market_tab.cpp index 9a8cefc..f5655a3 100644 --- a/src/ui/windows/market_tab.cpp +++ b/src/ui/windows/market_tab.cpp @@ -43,13 +43,19 @@ static float s_pair_drag_start_x = 0.0f; static float s_pair_drag_start_scroll = 0.0f; static bool s_market_state_loaded = false; +// The effective exchange registry: the live CoinGecko list when available, else the +// compiled-in fallback. +static const std::vector& EffectiveRegistry(const MarketInfo& market) +{ + return market.exchanges.empty() ? data::getExchangeRegistry() : market.exchanges; +} + // Helper: load selected exchange/pair from settings -static void LoadMarketState(config::Settings* settings) +static void LoadMarketState(config::Settings* settings, const std::vector& registry) { if (s_market_state_loaded || !settings) return; s_market_state_loaded = true; - const auto& registry = data::getExchangeRegistry(); std::string savedExchange = settings->getSelectedExchange(); std::string savedPair = settings->getSelectedPair(); @@ -104,11 +110,14 @@ void RenderMarketTab(App* app) const auto& state = app->getWalletState(); const auto& market = state.market; - // Load persisted exchange/pair on first frame - LoadMarketState(app->settings()); + // Kick a once-per-session live exchange-list fetch (self-guarded), then source the + // registry from it (falling back to the compiled-in list until it arrives). + app->refreshExchanges(); + const auto& registry = EffectiveRegistry(market); + + // Load persisted exchange/pair on first frame + LoadMarketState(app->settings(), registry); - // Exchange registry - const auto& registry = data::getExchangeRegistry(); if (s_exchange_idx >= (int)registry.size()) s_exchange_idx = 0; const auto& currentExchange = registry[s_exchange_idx]; if (s_pair_idx >= (int)currentExchange.pairs.size()) s_pair_idx = 0; @@ -787,6 +796,17 @@ void RenderMarketTab(App* app) snprintf(buf, sizeof(buf), "$%.6f USD", portfolio_usd); DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx, cy), Success(), buf); + // 24h change on the holdings (the coin's 24h % applies to the value), shown + // beside the USD figure and colored by direction. + if (market.change_24h != 0.0) { + float usdW = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, buf).x; + char chg[48]; + snprintf(chg, sizeof(chg), "%+.2f%% %s", market.change_24h, TR("market_24h_change")); + ImU32 chgCol = market.change_24h >= 0 ? Success() : Error(); + dl->AddText(capFont, capFont->LegacySize, + ImVec2(cx + usdW + Layout::spacingMd(), cy + 3), chgCol, chg); + } + double portfolio_btc = total_balance * market.price_btc; snprintf(buf, sizeof(buf), "%.10f BTC", portfolio_btc); float valW = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, buf).x; diff --git a/src/util/i18n.cpp b/src/util/i18n.cpp index bd77d56..9095661 100644 --- a/src/util/i18n.cpp +++ b/src/util/i18n.cpp @@ -1052,13 +1052,14 @@ void I18n::loadBuiltinEnglish() strings_["market_24h"] = "24h"; strings_["market_24h_volume"] = "24H VOLUME"; strings_["market_6h"] = "6h"; - strings_["market_attribution"] = "Price data from NonKYC"; + strings_["market_attribution"] = "Price data from CoinGecko"; strings_["market_btc_price"] = "BTC PRICE"; strings_["market_no_history"] = "No price history available"; strings_["market_no_price"] = "No price data"; strings_["market_now"] = "Now"; strings_["market_pct_shielded"] = "%.0f%% Shielded"; - strings_["market_portfolio"] = "PORTFOLIO"; + strings_["market_portfolio"] = "MY DRGX"; + strings_["market_24h_change"] = "24h"; strings_["market_price_loading"] = "Loading price data..."; strings_["market_price_unavailable"] = "Price data unavailable"; strings_["market_refresh_price"] = "Refresh price data";