feat(market): auto-populate exchange pairs from CoinGecko (adds OurBit); DRGX dashboard
- Fetch /coins/dragonx-2/tickers once per session (App::refreshExchanges, reusing the
price-fetch worker path) and build the exchange registry at runtime into
state.market.exchanges; parseCoinGeckoTickers groups tickers by venue. The Market tab
sources from this live list and falls back to the compiled-in registry (now including
OurBit + Nonkyc.io) when offline. Fixes the missing OurBit pair and auto-tracks future
listings.
- Fix stale selection defaults (TradeOgre / DRGX-BTC -> Nonkyc.io / DRGX/USDT) that never
matched the registry, and make the attribution generic ("Price data from CoinGecko").
- Reframe the single-asset portfolio card as a DRGX holdings dashboard: relabel to
"MY DRGX" and show the 24h change on the holdings value (colored by direction).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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<data::ExchangeInfo> 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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -4,11 +4,19 @@
|
||||
|
||||
#include "exchange_info.h"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <algorithm>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace dragonx {
|
||||
namespace data {
|
||||
|
||||
const std::vector<ExchangeInfo>& 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<ExchangeInfo> registry = {
|
||||
{
|
||||
"Nonkyc.io",
|
||||
@@ -17,9 +25,68 @@ const std::vector<ExchangeInfo>& 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<ExchangeInfo> parseCoinGeckoTickers(const std::string& body)
|
||||
{
|
||||
std::vector<ExchangeInfo> exchanges;
|
||||
try {
|
||||
const nlohmann::json j = nlohmann::json::parse(body);
|
||||
if (!j.contains("tickers") || !j["tickers"].is_array()) return {};
|
||||
|
||||
std::unordered_map<std::string, size_t> 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
|
||||
|
||||
@@ -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<ExchangeInfo>& 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<ExchangeInfo> parseCoinGeckoTickers(const std::string& json);
|
||||
|
||||
} // namespace data
|
||||
} // namespace dragonx
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#include <cstdint>
|
||||
#include <chrono>
|
||||
|
||||
#include "exchange_info.h"
|
||||
|
||||
namespace dragonx {
|
||||
|
||||
/**
|
||||
@@ -160,6 +162,10 @@ struct MarketInfo {
|
||||
// Price history for chart
|
||||
std::vector<double> 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<data::ExchangeInfo> exchanges;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<data::ExchangeInfo>& 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<data::ExchangeInfo>& 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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user