From ac554dc9e7639405b02fa26f23bd781c5ed8521e Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 18 Jul 2026 23:26:34 -0500 Subject: [PATCH 1/2] feat(#520): opt-in live XMR/XTM price feed over Tor + fiat estimates on the earnings card (#646) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#520): opt-in live XMR/XTM price feed over Tor + fiat estimates on the earnings card The deferred auto half of #520 (PR #614 shipped only the static prices). dashboard.energy.price_feed (default false) fetches both spot prices from CoinGecko in the operator's currency β€” always over the bridge Tor SOCKS, 15-min throttle, fail-silent, last-good-prices kept, static config prices as the fallback. The earnings card states which price is in use (source + age), and the Monero / Tari / XvB tabs grow β‰ˆ-fiat rows once a price is known. New egress surfaces in the #170 posture + topology; pithead's closed-schema energy validation gains the boolean key. Hardening from security review: non-finite prices rejected (a hostile NaN would break /api/state serialization) and the currency label must be plainly alphabetic before it may leave the host as a query parameter. Co-Authored-By: Claude Fable 5 * review: harden price feed (non-finite + currency-param guards), drop unused api_url knob Co-Authored-By: Claude Fable 5 * rebase: merge the #642 and #646 fixture-generator changes, regenerate state.json Both PRs evolved _gen_state.py: #642 added the recorded raffle win, this branch enriched the worker power blocks. The rebased generator carries both and state.json is its real output again. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: Claude Fable 5 --- README.md | 6 +- .../mining_dashboard/config/config.py | 14 +- .../mining_dashboard/service/data_service.py | 23 +++ .../mining_dashboard/service/egress.py | 18 +++ .../mining_dashboard/service/price_feed.py | 108 ++++++++++++++ .../web/static/components.mjs | 51 ++++++- .../mining_dashboard/web/static/logic.mjs | 32 ++++ build/dashboard/mining_dashboard/web/views.py | 34 ++++- build/dashboard/tests/config/test_config.py | 31 ++-- .../tests/frontend/components.test.mjs | 35 +++++ .../tests/frontend/fixtures/_gen_state.py | 11 +- .../tests/frontend/fixtures/state.json | 36 +++-- build/dashboard/tests/frontend/logic.test.mjs | 45 ++++++ .../tests/service/test_data_service.py | 20 +++ build/dashboard/tests/service/test_egress.py | 8 + .../tests/service/test_price_feed.py | 141 ++++++++++++++++++ build/dashboard/tests/web/test_views.py | 52 ++++++- config.reference.json | 1 + docs/configuration.md | 7 +- docs/dashboard.md | 31 ++-- docs/privacy.md | 1 + pithead | 12 +- tests/stack/run.sh | 10 +- 23 files changed, 653 insertions(+), 74 deletions(-) create mode 100644 build/dashboard/mining_dashboard/service/price_feed.py create mode 100644 build/dashboard/tests/service/test_price_feed.py diff --git a/README.md b/README.md index 9dc4223f..bc4a4b15 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,10 @@ a Tor daemon. The `pithead` script renders config, provisions Tor, and drives do alert channels; timezone, memory limits, and every privacy toggle β€” around 94 keys across 13 sections, all in one `config.json` and validated on every `apply`. Most have defaults you'll never touch. See [Configuration](docs/configuration.md). -- πŸ’‘ **Energy-aware earnings.** Set your electricity cost and XMR price and add each rig's watts; the - Energy tab shows fleet power draw, efficiency in hashes per watt, and estimated profit after power. +- πŸ’‘ **Energy-aware earnings.** Set your electricity cost and coin prices β€” typed in, or fetched + live from CoinGecko over Tor with the opt-in price feed β€” and add each rig's watts; the earnings + card shows fiat estimates per coin, fleet power draw, efficiency in hashes per watt, and estimated + profit after power, always stating which price the figures use. - πŸ“Ÿ **Telegram operator bot.** Opt-in alerts for a downed node, a worker that dropped off, sync finishing, low disk, a clearnet leak, or a sustained hashrate drop β€” plus a daily digest and read-only commands (`/status`, `/hashrate`, `/workers`, `/earnings`). Routed over Tor. The same diff --git a/build/dashboard/mining_dashboard/config/config.py b/build/dashboard/mining_dashboard/config/config.py index 548ef9ad..e8cfd542 100644 --- a/build/dashboard/mining_dashboard/config/config.py +++ b/build/dashboard/mining_dashboard/config/config.py @@ -312,13 +312,16 @@ def load_energy_config(path=None): loudly at apply. ``cost_per_kwh`` β€” electricity price per kWh (0/unset β‡’ cost + profit hidden, energy view only). - ``xmr_price`` β€” operator-supplied fiat price of 1 XMR (0/unset β‡’ net profit hidden). No price - feed ships: fetching one is a clearnet egress this privacy-first stack avoids, - so the operator supplies it, in the same ``currency`` as ``cost_per_kwh``. + ``xmr_price`` β€” operator-supplied fiat price of 1 XMR (0/unset β‡’ net profit hidden), in the + same ``currency`` as ``cost_per_kwh``. Static by default (no unbidden price + egress); the fallback when ``price_feed`` is on but hasn't fetched yet. ``tari_price`` β€” operator-supplied fiat price of 1 XTM (#520; 0/unset β‡’ net profit counts - P2Pool XMR only). Same no-price-feed reasoning as ``xmr_price``; folds Tari's - merge-mined earnings into net profit once both this and ``xmr_price`` are set. + P2Pool XMR only). Same static-by-default reasoning as ``xmr_price``; folds + Tari's merge-mined earnings into net profit once both prices are known. ``currency`` β€” display label for all figures (e.g. USD, EUR). Label only β€” no conversion. + ``price_feed`` β€” opt-in (default off): fetch both prices live from CoinGecko over Tor + (``service/price_feed.py``) instead of the static numbers above, which then + serve as the fallback until the first fetch lands. """ try: with open(path or HOST_CONFIG_PATH) as f: @@ -341,6 +344,7 @@ def _nonneg(v): "xmr_price": _nonneg(raw.get("xmr_price")), "tari_price": _nonneg(raw.get("tari_price")), "currency": currency, + "price_feed": raw.get("price_feed") is True, } diff --git a/build/dashboard/mining_dashboard/service/data_service.py b/build/dashboard/mining_dashboard/service/data_service.py index df2338b9..b02ad6bc 100644 --- a/build/dashboard/mining_dashboard/service/data_service.py +++ b/build/dashboard/mining_dashboard/service/data_service.py @@ -38,6 +38,7 @@ from mining_dashboard.config.config import ( CHECK_FOR_UPDATES, CLEARNET_STATE_DIR, + DASHBOARD_ENERGY, ENABLE_XVB, GITHUB_RELEASES_API, HASHRATE_DROP_MINUTES, @@ -71,6 +72,7 @@ from mining_dashboard.service.healthchecks import HealthchecksClient from mining_dashboard.service.metrics import build_metrics, share_reject_pct from mining_dashboard.service.node_health import NodeHealthMonitor +from mining_dashboard.service.price_feed import CoinGeckoClient, PriceFeed from mining_dashboard.service.telegram_commands import format_daily_summary from mining_dashboard.service.tor_heal import TorEgressHealer from mining_dashboard.service.update_checker import GitHubReleaseClient, UpdateChecker @@ -417,6 +419,12 @@ def __init__(self, state_manager, proxy_client, xvb_client): enabled=CHECK_FOR_UPDATES, interval=UPDATE_CHECK_INTERVAL, ) + # Live XMR/XTM price feed (#520's auto half): off unless dashboard.energy.price_feed is + # set. Same Tor SOCKS route as the update check β€” CoinGecko only ever sees a Tor exit. + self.price_feed = PriceFeed( + CoinGeckoClient(DASHBOARD_ENERGY["currency"], TOR_SOCKS_PROXY), + enabled=DASHBOARD_ENERGY["price_feed"], + ) # Share-health delta baseline (#116): the previous poll's cumulative proxy /summary # totals; None until the first poll seeds it (and again after a counter reset). self._last_share_totals = None @@ -774,6 +782,15 @@ async def _maybe_register_xvb(self, shares, p2pool_stats): self.state_manager.update_xvb_stats, registration_state="failing" ) + async def _sync_prices(self): + """Refresh the live XMR/XTM prices (#520) into ``latest_data["prices"]`` β€” a no-op with the + feed off. The PriceFeed self-throttles and keeps its last good result, so calling this every + poll is safe; ``build_energy`` swaps the result in for the static config prices.""" + if self.price_feed.enabled: + self.latest_data["prices"] = await asyncio.to_thread( + self.price_feed.maybe_fetch, time.time() + ) + async def _sync_payouts(self): """Confirm on-chain payouts from the view-only wallet-rpc (#381), throttled by the caller. @@ -1314,6 +1331,12 @@ async def run(self): self.update_checker.maybe_check, time.time() ) + # 9. Live XMR/XTM prices over Tor (#520) β€” ONLY when dashboard.energy.price_feed + # is set (default off, so the appliance never dials CoinGecko unbidden). The + # feed self-throttles (15 min) and keeps the last good prices on failure; + # surfaced as state.energy price fields via build_energy. + await self._sync_prices() + iteration_count += 1 except Exception as e: logger.error(f"Data Collection Error: {e}") diff --git a/build/dashboard/mining_dashboard/service/egress.py b/build/dashboard/mining_dashboard/service/egress.py index 58888eba..e4343b2b 100644 --- a/build/dashboard/mining_dashboard/service/egress.py +++ b/build/dashboard/mining_dashboard/service/egress.py @@ -41,6 +41,7 @@ def compute_egress_posture( remote_monero, healthchecks_enabled, telegram_enabled, + price_feed_enabled=False, ): """Pure derivation of the egress posture from config knobs. Returns ``{components, summary}``.""" xvb = _xvb_route(xvb_enabled, xvb_tor) @@ -102,6 +103,11 @@ def compute_egress_posture( {"to": "Healthchecks.io ping", "route": TOR if healthchecks_enabled else INACTIVE}, # Telegram bot (alerts + command long-poll) β€” always over Tor when on (#121/#340). {"to": "Telegram bot", "route": TOR if telegram_enabled else INACTIVE}, + # XMR/XTM price feed (#520) β€” always over Tor when opted in (energy.price_feed). + { + "to": "price feed (coingecko.com)", + "route": TOR if price_feed_enabled else INACTIVE, + }, ], }, { @@ -155,6 +161,7 @@ def egress_posture_from_config(): remote_monero=config.MONERO_NODE_HOST != config.LOCAL_MONERO_HOST, healthchecks_enabled=bool(config.HEALTHCHECKS_PING_URL), telegram_enabled=config.TELEGRAM_ENABLED, + price_feed_enabled=config.DASHBOARD_ENERGY["price_feed"], ) @@ -207,6 +214,7 @@ def compute_topology( remote_monero, healthchecks_enabled, telegram_enabled, + price_feed_enabled=False, ): """Pure derivation of the stack topology. Returns ``{nodes, edges, summary}``. @@ -224,6 +232,7 @@ def compute_topology( remote_monero=remote_monero, healthchecks_enabled=healthchecks_enabled, telegram_enabled=telegram_enabled, + price_feed_enabled=price_feed_enabled, ) xvb = _xvb_route(xvb_enabled, xvb_tor) sidechain = CLEARNET if p2pool_clearnet else TOR @@ -257,6 +266,14 @@ def compute_topology( "Telegram bot", "egress", ), + # XMR/XTM price feed (#520) β€” always over Tor when opted in (energy.price_feed). + _edge( + "dashboard", + "tor", + TOR if price_feed_enabled else INACTIVE, + "price feed", + "egress", + ), # The Tor hub to the network: SOCKS egress for every daemon + onion-service ingress. _edge("tor", "internet", TOR, "SOCKS + onion circuits", "p2p"), # Internal mesh (hidden until expanded). @@ -300,4 +317,5 @@ def topology_from_config(): remote_monero=config.MONERO_NODE_HOST != config.LOCAL_MONERO_HOST, healthchecks_enabled=bool(config.HEALTHCHECKS_PING_URL), telegram_enabled=config.TELEGRAM_ENABLED, + price_feed_enabled=config.DASHBOARD_ENERGY["price_feed"], ) diff --git a/build/dashboard/mining_dashboard/service/price_feed.py b/build/dashboard/mining_dashboard/service/price_feed.py new file mode 100644 index 00000000..f926b78b --- /dev/null +++ b/build/dashboard/mining_dashboard/service/price_feed.py @@ -0,0 +1,108 @@ +"""Live XMR/XTM price feed (#520's deferred auto half) β€” opt-in, over Tor. + +When ``dashboard.energy.price_feed`` is enabled (default OFF), the dashboard periodically fetches +the spot price of Monero and Tari in the operator's ``dashboard.energy.currency`` from CoinGecko +and uses them in place of the static ``xmr_price`` / ``tari_price`` numbers, so the calculator's +fiat figures track the market instead of going stale. CoinGecko is the one source that quotes both +coins in one call with no API key (CoinDesk doesn't list XTM; Yahoo has no stable free JSON API). + +Privacy: the fetch is **opt-in (default off)** and routed over the bridge **Tor SOCKS** (reusing +``TOR_SOCKS_PROXY``, the same path as the update check #224 and XvB stats #163), so enabling it +never reveals the host IP to CoinGecko. Every failure path is silent: a failed fetch keeps the last +good prices (the UI marks their age) and the static config prices remain the fallback until the +first fetch lands. +""" + +import logging +import math +import re + +import requests + +logger = logging.getLogger("PriceFeed") + +COINGECKO_SIMPLE_PRICE = "https://api.coingecko.com/api/v3/simple/price" +# CoinGecko coin ids for the two chains this stack mines. Tari is listed as "minotari" (XTM). +COINGECKO_IDS = {"xmr": "monero", "tari": "minotari"} + + +def parse_prices(data, currency): + """``{"xmr": float, "tari": float}`` from a CoinGecko ``simple/price`` payload, or ``None`` + when either coin (or the requested currency) is missing/invalid. Both-or-nothing on purpose: + a pair from two different fetches could disagree on the exchange rate. Pure + unit-tested.""" + cur = currency.lower() + try: + xmr = float(data[COINGECKO_IDS["xmr"]][cur]) + tari = float(data[COINGECKO_IDS["tari"]][cur]) + except (KeyError, TypeError, ValueError): + return None + # Reject non-finite explicitly: json.loads accepts the NaN/Infinity tokens, NaN <= 0 is False, + # and a NaN reaching /api/state would serialize as invalid JSON and break the whole dashboard + # fetch β€” a hostile response must degrade to "no fetch", never past this gate. + if not (math.isfinite(xmr) and math.isfinite(tari)) or xmr <= 0 or tari <= 0: + return None + return {"xmr": xmr, "tari": tari} + + +class CoinGeckoClient: + """Fetches the XMR + XTM spot prices from CoinGecko, fail-silent over Tor.""" + + def __init__(self, currency, tor_proxy=None): + self.currency = currency + self.tor_proxy = tor_proxy + + def fetch(self): + """Return ``{"xmr": ..., "tari": ...}`` in ``self.currency``, or ``None`` on any failure + (network, non-200, malformed JSON, unsupported currency). Routed through Tor when set.""" + # ``currency`` doubles as a free-form display label (any printable ASCII passes pithead's + # validation, and dashboard.energy is dashboard-committable, #504) β€” but only a plain + # alphabetic code may leave the host as a query parameter, so a committed label can never + # become an exfil channel through the feed's URL. Anything else: no fetch, static fallback. + if not re.fullmatch(r"[A-Za-z]{2,5}", self.currency): + return None + proxies = {"http": self.tor_proxy, "https": self.tor_proxy} if self.tor_proxy else None + try: + resp = requests.get( + COINGECKO_SIMPLE_PRICE, + params={ + "ids": ",".join(COINGECKO_IDS.values()), + "vs_currencies": self.currency.lower(), + }, + timeout=20, + proxies=proxies, + headers={"User-Agent": "pithead-dashboard"}, + ) + if resp.status_code != 200: + return None + return parse_prices(resp.json(), self.currency) + except (requests.RequestException, ValueError) as e: + logger.debug("Price fetch failed (kept silent): %s", e) + return None + + +class PriceFeed: + """Throttled wrapper around the client (the ``UpdateChecker`` pattern, #224). ``maybe_fetch`` + hits the network at most once per ``interval`` and otherwise returns the cached result; a + failed fetch keeps the previous prices (the UI shows their age) rather than dropping them.""" + + def __init__(self, client, enabled, interval=900): + self.client = client + self.enabled = enabled + self.interval = interval + self._last = 0.0 + self.result = None + + def maybe_fetch(self, now): + """Return the cached ``{xmr, tari, currency, fetched_at}`` (or ``None`` before the first + successful fetch). Performs the (blocking) fetch only when enabled and the throttle window + has elapsed β€” call via ``asyncio.to_thread``.""" + if not self.enabled: + self.result = None + return None + if self._last and (now - self._last) < self.interval: + return self.result + self._last = now + prices = self.client.fetch() + if prices: + self.result = {**prices, "currency": self.client.currency, "fetched_at": now} + return self.result diff --git a/build/dashboard/mining_dashboard/web/static/components.mjs b/build/dashboard/mining_dashboard/web/static/components.mjs index 6d053056..1e44b0c4 100644 --- a/build/dashboard/mining_dashboard/web/static/components.mjs +++ b/build/dashboard/mining_dashboard/web/static/components.mjs @@ -6,18 +6,21 @@ import { ChartCard } from "./chart.mjs"; import { ConfigView, UpgradeControl } from "./configview.mjs"; import { + coinFiat, computeEarnings, computeEnergy, computeXvbTier, egressRoute, fmtHashrate, formatFiat, + formatFiatPrice, formatTimeToShare, formatUnit, formatXmr, formatXtm, heroKpis, parseHashrate, + priceSourceLabel, raffleCls, sortWorkers, THEME_LABELS, @@ -387,7 +390,7 @@ class XvbComparison extends Component { } render() { - const { calc, coeffDay, hr } = this.props; + const { calc, coeffDay, hr, energy } = this.props; const tiers = (calc && calc.tiers) || []; if (!tiers.length) return null; const { selected } = this.state; @@ -423,6 +426,17 @@ class XvbComparison extends Component { : "Not shown β€” this tier isn't sustainable at your hashrate, so its payout isn't reachable." } /> + ${ + // Fiat mirror of the XMR/yr figures (#520): same visibility guards as the cards + // above (never a fiat number whose XMR figure is hidden), at the XMR price in use. + energy && energy.xmr_price > 0 + ? html`

+ β‰ˆ ${calc.estimates_available ? formatFiat(coinFiat(cmp.expected, energy.xmr_price), energy.currency) : "β€”"} expected Β· + ${formatFiat(coinFiat(cmp.cost, energy.xmr_price), energy.currency)} cost Β· + ${sustainable ? formatFiat(coinFiat(cmp.net, energy.xmr_price), energy.currency) : "β€”"} net, per year +

` + : null + } ${ !sustainable ? html`

Not sustainable at your hashrate β€” holding this tier needs about ${fmtHashrate(sel.threshold)} donated continuously, more than your hashrate can spare.

` @@ -445,7 +459,7 @@ class XvbComparison extends Component { // raffle status, never a payout; deliberately no entry counts or win odds β€” the draw is random // above the threshold. Hidden entirely while XvB is disabled. `coeffDay` (earnings.coeff_day) // feeds the per-tier payout comparison dropdown below. -function XvbTierBlock({ calc, hr, coeffDay }) { +function XvbTierBlock({ calc, hr, coeffDay, energy }) { if (!calc || !calc.enabled) return null; const t = computeXvbTier(hr, calc); return html` @@ -461,7 +475,7 @@ function XvbTierBlock({ calc, hr, coeffDay }) { <${StatCard} label="Target Tier" value=${calc.target_tier} title=${"The tier the donation controller is configured to aim for" + (calc.sustainable ? "." : " β€” currently NOT sustainable at your hashrate.")} /> - <${XvbComparison} calc=${calc} coeffDay=${coeffDay} hr=${hr} /> + <${XvbComparison} calc=${calc} coeffDay=${coeffDay} hr=${hr} energy=${energy} />

${calc.note}${calc.mode_note ? " " + calc.mode_note : ""}

`; } @@ -511,6 +525,11 @@ class EarningsCard extends Component { if (xvb && xvb.enabled) tabs.push({ id: "xvb", label: "XvB" }); if (energy && energy.available) tabs.push({ id: "energy", label: "Energy" }); const active = tabs.some((t) => t.id === tab) ? tab : "monero"; + // Fiat estimates (#520): each tab grows β‰ˆ-fiat rows once its coin's price is known β€” static + // from config.json or live from the opt-in CoinGecko-over-Tor feed. The footer line below + // states which price the figures are valued at, so a fiat number is never unattributed. + const priceSrc = priceSourceLabel(energy); + const priceTitle = "At the price shown in the Prices line below β€” an estimate, not a payout."; return html`

P2Pool Earnings (estimated)

@@ -537,6 +556,14 @@ class EarningsCard extends Component { <${StatCard} label="XMR / day" value=${formatXmr(est.day)} cls="text-accent" /> <${StatCard} label="XMR / month" value=${formatXmr(est.month)} cls="text-accent" /> <${StatCard} label="XMR / year" value=${formatXmr(est.year)} cls="text-accent" /> + ${ + energy && energy.xmr_price > 0 + ? html` + <${StatCard} label="β‰ˆ / day" value=${formatFiat(coinFiat(est.day, energy.xmr_price), energy.currency)} title=${priceTitle} /> + <${StatCard} label="β‰ˆ / month" value=${formatFiat(coinFiat(est.month, energy.xmr_price), energy.currency)} title=${priceTitle} /> + <${StatCard} label="β‰ˆ / year" value=${formatFiat(coinFiat(est.year, energy.xmr_price), energy.currency)} title=${priceTitle} />` + : null + } <${StatCard} label="Time / Share" value=${formatTimeToShare(est.timeToShareSec)} /> <${StatCard} label="XMR Block Reward" value=${e.block_reward} />
@@ -550,6 +577,13 @@ class EarningsCard extends Component { title="The full Tari block reward paid when you solo-find a block β€” you get all of it at once, not spread over time." /> <${StatCard} label="XTM / day (avg)" value=${formatXtm(est.tariDay)} title="Long-run average, NOT steady income. Solo merge-mining pays the whole block reward at once, roughly every 'time to Tari block' β€” this per-day figure just spreads that lumpy payout out on paper." /> + ${ + energy && energy.tari_price > 0 + ? html` + <${StatCard} label="β‰ˆ per Block" value=${formatFiat(coinFiat(est.tariRewardPerBlock, energy.tari_price), energy.currency)} title=${priceTitle} /> + <${StatCard} label="β‰ˆ / day (avg)" value=${formatFiat(coinFiat(est.tariDay, energy.tari_price), energy.currency)} title=${priceTitle} />` + : null + } @@ -557,7 +591,7 @@ class EarningsCard extends Component { xvb && xvb.enabled ? html` ` : null } @@ -569,6 +603,13 @@ class EarningsCard extends Component { ` : null } + ${ + priceSrc + ? html`

+ Prices: XMR ${formatFiatPrice(energy.xmr_price, energy.currency)} Β· XTM ${formatFiatPrice(energy.tari_price, energy.currency)} β€” ${priceSrc} +

` + : null + }

${e.disclaimer}

`; } @@ -626,7 +667,7 @@ function EnergyPanel({ energy, est }) { <${StatCard} label="Net / year" value=${formatFiat(en.netYear, cur)} cls=${en.netYear !== null && en.netYear < 0 ? "c-bad" : "text-accent"} />` : html`<${StatCard} label="Net Profit" value="set xmr_price" - title="Set dashboard.energy.xmr_price (in your currency) to see net profit after power. No price feed ships β€” this stack avoids the clearnet egress." />` + title="Set dashboard.energy.xmr_price (in your currency), or dashboard.energy.price_feed: true to fetch live prices from CoinGecko over Tor (opt-in β€” off by default, no clearnet egress)." />` } ` : html`

Set dashboard.energy.cost_per_kwh to see energy cost and net profit after power.

` diff --git a/build/dashboard/mining_dashboard/web/static/logic.mjs b/build/dashboard/mining_dashboard/web/static/logic.mjs index cfab937b..163f6a1d 100644 --- a/build/dashboard/mining_dashboard/web/static/logic.mjs +++ b/build/dashboard/mining_dashboard/web/static/logic.mjs @@ -352,6 +352,38 @@ export function formatFiat(value, currency) { return sign + (currency || "USD") + " " + Math.abs(value).toFixed(2); } +// Fiat value of a coin estimate (#520 price feed): null unless both the estimate and a positive +// price exist, so the coin tabs only grow fiat rows once a price is configured or fetched. +export function coinFiat(value, price) { + return Number.isFinite(value) && Number.isFinite(price) && price > 0 ? value * price : null; +} + +// Format a fiat *price* (a per-coin exchange rate, #520). Unlike formatFiat (aggregates, 2 dp) a +// price can be tiny β€” XTM trades at fractions of a cent β€” so scale the decimals like formatCoin +// does, or the price line would read "USD 0.00". "β€”" when the price is unset (0/invalid). +export function formatFiatPrice(value, currency) { + if (!Number.isFinite(value) || value <= 0) return "β€”"; + const dp = value >= 1 ? 2 : value >= 0.001 ? 4 : 6; + return (currency || "USD") + " " + value.toFixed(dp); +} + +// Provenance of the prices in use (#520): the calculator always says which price its fiat figures +// are valued at β€” live feed (with age), feed-enabled-but-waiting (static still in use), or static +// config. Null when the feed is off and no price is set: there are no fiat figures to attribute. +export function priceSourceLabel(energy) { + if (!energy) return null; + const src = energy.price_source || {}; + const havePrice = energy.xmr_price > 0 || energy.tari_price > 0; + if (src.live) { + const age = Number.isFinite(src.age_sec) + ? ", updated " + fmtWindowDuration(src.age_sec * 1000) + " ago" + : ""; + return "live from CoinGecko over Tor" + age; + } + if (src.feed) return "price feed waiting for its first fetch β€” static config.json values in use"; + return havePrice ? "static, set in config.json" : null; +} + // Format a power draw / energy figure with its unit (W, kWh); "β€”" for the null/invalid case. export function formatUnit(value, unit, dp = 1) { if (value === null || value === undefined || !Number.isFinite(value)) return "β€”"; diff --git a/build/dashboard/mining_dashboard/web/views.py b/build/dashboard/mining_dashboard/web/views.py index d35bc7f0..fed06c64 100644 --- a/build/dashboard/mining_dashboard/web/views.py +++ b/build/dashboard/mining_dashboard/web/views.py @@ -906,8 +906,9 @@ def build_workers(workers): "Power draw is measured (RAPL, 15s sample) or your per-worker estimate; a worker reporting " "neither is excluded and the fleet total is marked incomplete. kWh and cost extrapolate the " "current draw at a constant rate β€” a naive projection, not a metered bill. Net profit is " - "P2Pool XMR earnings valued at your XMR price, plus Tari merge-mining earnings valued at your " - "Tari price once dashboard.energy.tari_price is set (0/unset counts P2Pool XMR only) β€” minus " + "P2Pool XMR earnings valued at the XMR price in use (your configured price, or the live " + "CoinGecko-over-Tor feed when dashboard.energy.price_feed is on), plus Tari merge-mining " + "earnings valued at the Tari price when one is set (0/unset counts P2Pool XMR only) β€” minus " "power cost. XvB stays excluded: it's raffle status, not a clean per-day income estimate. " "Estimates, not guarantees." ) @@ -921,7 +922,7 @@ def _worker_watts_config(name): return None -def build_energy(workers): +def build_energy(workers, prices=None): """Fleet energy inputs for the earnings card's Energy tab (Issue #260). Sums each worker's power draw β€” measured watts from the RigForge enriched feed (#235) first, else @@ -932,8 +933,23 @@ def build_energy(workers): Publishes the summed watts + prices; the client scales to kWh / cost / net per dayΒ·monthΒ·year (``computeEnergy`` in ``logic.mjs``). ``available`` is False only when no worker reports or is - configured with any power β€” the card then shows nothing rather than a zero-watt fleet.""" + configured with any power β€” the card then shows nothing rather than a zero-watt fleet. + + ``prices`` is the live feed result (#520, ``state.prices`` β€” ``{xmr, tari, currency, + fetched_at}`` or None). When the feed is enabled and has fetched, the live prices replace the + static config numbers and ``price_source`` says so (with their age) β€” the calculator always + states which price it's using. Until the first fetch (or with the feed off) the static config + prices stand.""" cfg = config.DASHBOARD_ENERGY + live = prices if cfg["price_feed"] else None + price_source = { + # feed: the operator turned dashboard.energy.price_feed on; live: a fetch has landed. + "feed": cfg["price_feed"], + "live": bool(live), + "age_sec": round(time.time() - live["fetched_at"]) if live else None, + } + xmr_price = live["xmr"] if live else cfg["xmr_price"] + tari_price = live["tari"] if live else cfg["tari_price"] per_worker = [] total_watts = 0.0 powered_hs = 0.0 @@ -973,9 +989,10 @@ def build_energy(workers): "hs_per_watt": round(powered_hs / total_watts, 2) if have_power else None, "incomplete": incomplete, "cost_per_kwh": cfg["cost_per_kwh"], - "xmr_price": cfg["xmr_price"], - "tari_price": cfg["tari_price"], + "xmr_price": xmr_price, + "tari_price": tari_price, "currency": cfg["currency"], + "price_source": price_source, "per_worker": per_worker, "disclaimer": _ENERGY_DISCLAIMER, } @@ -1601,8 +1618,9 @@ def build_state(data, state_mgr, range_arg, window=None, avg_window=DEFAULT_HASH "xvb_calc": build_xvb_calc(metrics, state_mgr), "tari": build_tari(data), "workers": build_workers(data.get("workers", [])), - # Fleet power draw / efficiency and (once a price is set) net profit after power (#260). - "energy": build_energy(data.get("workers", [])), + # Fleet power draw / efficiency and (once a price is set) net profit after power (#260), + # with live feed prices (#520) when dashboard.energy.price_feed is on. + "energy": build_energy(data.get("workers", []), data.get("prices")), "proxy_summary": build_proxy_summary(data), # Persisted per-poll share-health deltas + trailing 24h reject rate (#116). Kept out of # proxy_summary so its (cumulative) shape stays unchanged for existing clients. diff --git a/build/dashboard/tests/config/test_config.py b/build/dashboard/tests/config/test_config.py index 27e1bf47..fbef7720 100644 --- a/build/dashboard/tests/config/test_config.py +++ b/build/dashboard/tests/config/test_config.py @@ -291,7 +291,15 @@ def test_neither_shape_set_reads_empty(self, tmp_path): class TestEnergyConfig: """dashboard.energy loader (#260): operator-set electricity + XMR prices, read off the config.json mount. Every field optional; an invalid value degrades to its default (feature off) rather than - crashing β€” no price feed ships, so both prices are supplied by the operator.""" + crashing. Prices are operator-set, or fetched live over Tor when `price_feed` opts in (#520).""" + + DEFAULTS = { + "cost_per_kwh": 0.0, + "xmr_price": 0.0, + "tari_price": 0.0, + "currency": "USD", + "price_feed": False, + } def _load(self, tmp_path, payload): from mining_dashboard.config.config import load_energy_config @@ -301,12 +309,7 @@ def _load(self, tmp_path, payload): return load_energy_config(str(p)) def test_defaults_when_absent(self, tmp_path): - assert self._load(tmp_path, {"dashboard": {}}) == { - "cost_per_kwh": 0.0, - "xmr_price": 0.0, - "tari_price": 0.0, - "currency": "USD", - } + assert self._load(tmp_path, {"dashboard": {}}) == self.DEFAULTS def test_valid_values_load(self, tmp_path): got = self._load( @@ -318,6 +321,7 @@ def test_valid_values_load(self, tmp_path): "xmr_price": 150, "tari_price": 2.5, "currency": "EUR", + "price_feed": True, } } }, @@ -327,6 +331,7 @@ def test_valid_values_load(self, tmp_path): "xmr_price": 150.0, "tari_price": 2.5, "currency": "EUR", + "price_feed": True, } def test_invalid_values_degrade_to_defaults(self, tmp_path): @@ -339,23 +344,19 @@ def test_invalid_values_degrade_to_defaults(self, tmp_path): "xmr_price": "expensive", "tari_price": -2, "currency": "has space", + "price_feed": "yes", # only literal true opts in β€” a truthy string doesn't } } }, ) - assert got == {"cost_per_kwh": 0.0, "xmr_price": 0.0, "tari_price": 0.0, "currency": "USD"} + assert got == self.DEFAULTS def test_non_object_energy_degrades_to_defaults(self, tmp_path): # A hand-edited `energy` that isn't an object mustn't crash β€” fall back to defaults. got = self._load(tmp_path, {"dashboard": {"energy": "nope"}}) - assert got == {"cost_per_kwh": 0.0, "xmr_price": 0.0, "tari_price": 0.0, "currency": "USD"} + assert got == self.DEFAULTS def test_missing_file_reads_defaults(self, tmp_path): from mining_dashboard.config.config import load_energy_config - assert load_energy_config(str(tmp_path / "absent.json")) == { - "cost_per_kwh": 0.0, - "xmr_price": 0.0, - "tari_price": 0.0, - "currency": "USD", - } + assert load_energy_config(str(tmp_path / "absent.json")) == self.DEFAULTS diff --git a/build/dashboard/tests/frontend/components.test.mjs b/build/dashboard/tests/frontend/components.test.mjs index 3077b5a7..2a6b133d 100644 --- a/build/dashboard/tests/frontend/components.test.mjs +++ b/build/dashboard/tests/frontend/components.test.mjs @@ -281,6 +281,41 @@ test('EarningsCard Energy tab keeps the P2Pool-only label when tari_price is set assert.doesNotMatch(html, /P2Pool \+ Tari, after power/); }); +test('EarningsCard grows fiat rows + a price provenance line once a price is known (#520)', () => { + const s = clone(); + s.earnings.available = true; + s.earnings.coeff_day = 1e-8; + s.earnings.tari_available = true; + s.earnings.tari_coeff_day = 1e-6; + // No price anywhere β†’ no fiat rows, no provenance line (nothing to attribute). + let html = renderApp({ state: s }); + assert.doesNotMatch(html, /β‰ˆ \/ day/); + assert.doesNotMatch(html, /id="earnings-price-source"/); + // Static prices set β†’ Monero + Tari tabs grow β‰ˆ-fiat rows, XvB gets its fiat mirror line, + // and the footer attributes the prices to config.json. + s.energy.xmr_price = 150; + s.energy.tari_price = 2; + html = renderApp({ state: s }); + assert.match(html, /β‰ˆ \/ day/); // Monero tab fiat rows + assert.match(html, /β‰ˆ per Block/); // Tari tab fiat rows + assert.match(html, /id="xvb-fiat-line"/); // XvB tab fiat mirror + assert.match(html, /id="earnings-price-source"/); + assert.match(html, /USD 150\.00/); // the XMR price in use, stated + assert.match(html, /static, set in config\.json/); +}); + +test('EarningsCard provenance line reflects the live price feed (#520)', () => { + const s = clone(); + s.earnings.available = true; + s.energy.xmr_price = 333.97; + s.energy.tari_price = 0.0004; + s.energy.price_source = { feed: true, live: true, age_sec: 720 }; + const html = renderApp({ state: s }); + assert.match(html, /live from CoinGecko over Tor/); + assert.match(html, /12m ago/); + assert.match(html, /USD 0\.000400/); // tiny XTM price keeps its precision +}); + test('XvB comparison dropdown shows Expected/Cost/Net per tier, degrades on a stale estimate (#118)', () => { const base = clone(); base.earnings.available = true; diff --git a/build/dashboard/tests/frontend/fixtures/_gen_state.py b/build/dashboard/tests/frontend/fixtures/_gen_state.py index d74d79c1..af03e3be 100644 --- a/build/dashboard/tests/frontend/fixtures/_gen_state.py +++ b/build/dashboard/tests/frontend/fixtures/_gen_state.py @@ -32,10 +32,9 @@ {"timestamp": NOW - 300, "v": 10500, "v_p2pool": 8100, "v_xvb": 2400, "t": "b"}, ] +# `h60` + the RigForge enriched `power` block feed build_energy (#260), so the fixture carries an +# available energy block (watts, efficiency) for the EarningsCard Energy-tab render tests. WORKERS = [ - # rig-alpha carries a RigForge power reading (#260) so the fixture's energy block is - # available (Energy tab rendered) exactly as build_energy computes it β€” the energy figures - # in state.json are real build_state output, never hand-edited. { "name": "rig-alpha", "ip": "192.168.1.10", @@ -47,7 +46,7 @@ "hashrate_1m": 5100, "hashrate_15m": 5000, "h60": 5100, - "rigforge": {"power": {"watts": 142.0}}, + "rigforge": {"power": {"watts": 142.0, "hs_per_watt": 35.9}}, }, { "name": "rig-bravo", @@ -59,8 +58,8 @@ "hashrate_10s": 0, "hashrate_1m": 0, "hashrate_15m": 4800, - "h60": 5100, - "rigforge": {"power": {"watts": 143.0}}, + "h60": 4800, + "rigforge": {"power": {"watts": 143.0, "hs_per_watt": 33.6}}, }, ] diff --git a/build/dashboard/tests/frontend/fixtures/state.json b/build/dashboard/tests/frontend/fixtures/state.json index 186f8485..307bde57 100644 --- a/build/dashboard/tests/frontend/fixtures/state.json +++ b/build/dashboard/tests/frontend/fixtures/state.json @@ -170,6 +170,10 @@ { "route": "inactive", "to": "Telegram bot" + }, + { + "route": "inactive", + "to": "price feed (coingecko.com)" } ], "firewalled": false, @@ -199,8 +203,8 @@ "available": true, "cost_per_kwh": 0.0, "currency": "USD", - "disclaimer": "Power draw is measured (RAPL, 15s sample) or your per-worker estimate; a worker reporting neither is excluded and the fleet total is marked incomplete. kWh and cost extrapolate the current draw at a constant rate \u2014 a naive projection, not a metered bill. Net profit is P2Pool XMR earnings valued at your XMR price, plus Tari merge-mining earnings valued at your Tari price once dashboard.energy.tari_price is set (0/unset counts P2Pool XMR only) \u2014 minus power cost. XvB stays excluded: it's raffle status, not a clean per-day income estimate. Estimates, not guarantees.", - "hs_per_watt": 35.79, + "disclaimer": "Power draw is measured (RAPL, 15s sample) or your per-worker estimate; a worker reporting neither is excluded and the fleet total is marked incomplete. kWh and cost extrapolate the current draw at a constant rate \u2014 a naive projection, not a metered bill. Net profit is P2Pool XMR earnings valued at the XMR price in use (your configured price, or the live CoinGecko-over-Tor feed when dashboard.energy.price_feed is on), plus Tari merge-mining earnings valued at the Tari price when one is set (0/unset counts P2Pool XMR only) \u2014 minus power cost. XvB stays excluded: it's raffle status, not a clean per-day income estimate. Estimates, not guarantees.", + "hs_per_watt": 34.74, "incomplete": false, "per_worker": [ { @@ -212,12 +216,17 @@ }, { "estimated": false, - "hs": 5100, - "hs_per_watt": 35.66, + "hs": 4800, + "hs_per_watt": 33.57, "name": "rig-bravo", "watts": 143.0 } ], + "price_source": { + "age_sec": null, + "feed": false, + "live": false + }, "tari_price": 0.0, "total_watts": 285.0, "xmr_price": 0.0 @@ -443,6 +452,13 @@ "route": "inactive", "to": "tor" }, + { + "from": "dashboard", + "kind": "egress", + "label": "price feed", + "route": "inactive", + "to": "tor" + }, { "from": "tor", "kind": "p2p", @@ -605,7 +621,7 @@ "rigforge": { "chips": [ { - "text": "142 W", + "text": "142 W \u00b7 35.9 H/s\u00b7W", "title": "Power draw / efficiency.", "variant": "outline" } @@ -615,7 +631,7 @@ { "label": "Power / efficiency", "title": "Power draw / efficiency.", - "value": "142 W", + "value": "142 W \u00b7 35.9 H/s\u00b7W", "variant": "outline" } ], @@ -631,8 +647,8 @@ "api_ok": null, "h15": 0, "h15_str": "0.00 H/s", - "h60": 5100, - "h60_str": "5.10 kH/s", + "h60": 4800, + "h60_str": "4.80 kH/s", "invalid": 0, "ip": "192.168.1.11", "ip_sort": 3232235787, @@ -644,7 +660,7 @@ "rigforge": { "chips": [ { - "text": "143 W", + "text": "143 W \u00b7 33.6 H/s\u00b7W", "title": "Power draw / efficiency.", "variant": "outline" } @@ -654,7 +670,7 @@ { "label": "Power / efficiency", "title": "Power draw / efficiency.", - "value": "143 W", + "value": "143 W \u00b7 33.6 H/s\u00b7W", "variant": "outline" } ], diff --git a/build/dashboard/tests/frontend/logic.test.mjs b/build/dashboard/tests/frontend/logic.test.mjs index cb916852..afa95c71 100644 --- a/build/dashboard/tests/frontend/logic.test.mjs +++ b/build/dashboard/tests/frontend/logic.test.mjs @@ -18,6 +18,7 @@ import { heroKpis, raffleCls, parseHashrate, fmtHashrate, computeEarnings, computeXvbTier, xvbTierComparison, formatXmr, formatXtm, formatTimeToShare, computeEnergy, formatFiat, formatUnit, + coinFiat, formatFiatPrice, priceSourceLabel, DAYS_PER_MONTH, DAYS_PER_YEAR, bandBorderWidth, uptimeCell, egressRoute, boxAnchor, @@ -534,6 +535,50 @@ test('computeEnergy: only xmr_price set -> P2Pool-only net, includesTari false', assert.equal(en.includesTari, false); }); +// --- Fiat estimates + price provenance (#520 price feed) --------------------------------- + +test('coinFiat: multiplies only when both estimate and positive price exist', () => { + assert.equal(coinFiat(0.1, 150), 15); + assert.equal(coinFiat(null, 150), null); // no estimate -> no fiat row + assert.equal(coinFiat(0.1, 0), null); // unset price -> no fiat row + assert.equal(coinFiat(0.1, undefined), null); +}); + +test('formatFiatPrice: scales decimals so a tiny XTM price never reads 0.00', () => { + assert.equal(formatFiatPrice(333.97, 'USD'), 'USD 333.97'); + assert.equal(formatFiatPrice(0.0004, 'USD'), 'USD 0.000400'); + assert.equal(formatFiatPrice(0.0521, 'EUR'), 'EUR 0.0521'); + assert.equal(formatFiatPrice(0, 'USD'), 'β€”'); // unset price +}); + +test('priceSourceLabel: live feed states source and age', () => { + const label = priceSourceLabel({ + xmr_price: 333.97, tari_price: 0.0004, currency: 'USD', + price_source: { feed: true, live: true, age_sec: 720 }, + }); + assert.match(label, /CoinGecko over Tor/); + assert.match(label, /12m ago/); +}); + +test('priceSourceLabel: feed waiting vs static vs nothing to attribute', () => { + // Feed on, first fetch pending -> says the static values still stand. + assert.match( + priceSourceLabel({ xmr_price: 150, tari_price: 0, price_source: { feed: true, live: false } }), + /waiting.*static/, + ); + // Feed off with a static price -> attributed to config.json. + assert.match( + priceSourceLabel({ xmr_price: 150, tari_price: 0, price_source: { feed: false, live: false } }), + /static, set in config\.json/, + ); + // No prices, no feed -> null (no fiat figures exist, nothing to attribute). + assert.equal( + priceSourceLabel({ xmr_price: 0, tari_price: 0, price_source: { feed: false, live: false } }), + null, + ); + assert.equal(priceSourceLabel(null), null); +}); + test('computeEnergy: tari_price set but no xmr_price -> no net at all (xmr_price is the base gate)', () => { const en = computeEnergy( { available: true, total_watts: 1000, cost_per_kwh: 0.2, xmr_price: 0, tari_price: 2 }, diff --git a/build/dashboard/tests/service/test_data_service.py b/build/dashboard/tests/service/test_data_service.py index 447fdd59..00ad6ad0 100644 --- a/build/dashboard/tests/service/test_data_service.py +++ b/build/dashboard/tests/service/test_data_service.py @@ -2113,3 +2113,23 @@ async def test_table_health_reflects_a_forced_block_write_failure(self): svc, p2pool_stats={"pool": {"last_share_time": 0, "difficulty": 0, "blocks_found": 6}} ) assert sm.get_table_health()["blocks"]["healthy"] is False + + +class TestSyncPrices: + """#520: the poll-loop price refresh β€” a no-op with the feed off, the cached PriceFeed result + surfaced as latest_data["prices"] when on.""" + + async def test_disabled_feed_never_fetches(self): + svc, _, _ = _make_service() + svc.price_feed = MagicMock(enabled=False) + await svc._sync_prices() + svc.price_feed.maybe_fetch.assert_not_called() + assert "prices" not in svc.latest_data + + async def test_enabled_feed_surfaces_prices(self): + svc, _, _ = _make_service() + prices = {"xmr": 333.97, "tari": 0.0004, "currency": "USD", "fetched_at": 1000.0} + svc.price_feed = MagicMock(enabled=True) + svc.price_feed.maybe_fetch.return_value = prices + await svc._sync_prices() + assert svc.latest_data["prices"] == prices diff --git a/build/dashboard/tests/service/test_egress.py b/build/dashboard/tests/service/test_egress.py index 7b38f129..ba004457 100644 --- a/build/dashboard/tests/service/test_egress.py +++ b/build/dashboard/tests/service/test_egress.py @@ -102,6 +102,14 @@ def test_telegram_bot_is_tor_when_enabled_inactive_otherwise(): assert on["summary"]["leaks"] == 0 # Tor-routed, so never a leak +def test_price_feed_is_tor_when_enabled_inactive_otherwise(): + # Opting into the price feed adds a dashboard Tor egress (#520); off β†’ inactive, never a leak. + assert _conn(_posture(price_feed_enabled=False), "dashboard", "price feed")["route"] == INACTIVE + on = _posture(price_feed_enabled=True, firewall=True) + assert _conn(on, "dashboard", "price feed")["route"] == TOR + assert on["summary"]["leaks"] == 0 # Tor-routed, so never a leak + + def test_remote_monerod_rpc_is_clearnet(): assert _conn(_posture(remote_monero=False), "p2pool", "monerod RPC")["route"] != CLEARNET assert _conn(_posture(remote_monero=True), "p2pool", "monerod RPC")["route"] == CLEARNET diff --git a/build/dashboard/tests/service/test_price_feed.py b/build/dashboard/tests/service/test_price_feed.py new file mode 100644 index 00000000..fbc19238 --- /dev/null +++ b/build/dashboard/tests/service/test_price_feed.py @@ -0,0 +1,141 @@ +"""Tests for the opt-in XMR/XTM price feed over Tor (#520).""" + +from unittest.mock import MagicMock, patch + +import requests + +from mining_dashboard.service.price_feed import CoinGeckoClient, PriceFeed, parse_prices + + +class TestParsePrices: + def test_parses_both_coins_in_currency(self): + data = {"monero": {"usd": 333.97}, "minotari": {"usd": 0.0004}} + assert parse_prices(data, "USD") == {"xmr": 333.97, "tari": 0.0004} + + def test_currency_is_case_insensitive(self): + data = {"monero": {"eur": 292.0}, "minotari": {"eur": 0.0003}} + assert parse_prices(data, "EUR") == {"xmr": 292.0, "tari": 0.0003} + + def test_missing_either_coin_is_none(self): + # Both-or-nothing: a lone XMR price must not half-update the pair. + assert parse_prices({"monero": {"usd": 333.0}}, "USD") is None + assert parse_prices({"minotari": {"usd": 0.0004}}, "USD") is None + + def test_unsupported_currency_is_none(self): + data = {"monero": {"usd": 333.0}, "minotari": {"usd": 0.0004}} + assert parse_prices(data, "XYZ") is None + + def test_garbage_and_nonpositive_are_none(self): + assert parse_prices({"monero": None, "minotari": None}, "USD") is None + assert parse_prices({"monero": {"usd": "x"}, "minotari": {"usd": 1}}, "USD") is None + assert parse_prices({"monero": {"usd": 0}, "minotari": {"usd": 0.0004}}, "USD") is None + assert parse_prices({"monero": {"usd": -1}, "minotari": {"usd": 0.0004}}, "USD") is None + + def test_nonfinite_is_none(self): + # json.loads accepts the NaN/Infinity tokens and NaN <= 0 is False β€” a hostile response + # must never push a non-finite number into /api/state (it would serialize as invalid JSON). + for evil in (float("nan"), float("inf"), float("-inf")): + assert parse_prices({"monero": {"usd": evil}, "minotari": {"usd": 1}}, "USD") is None + assert parse_prices({"monero": {"usd": 1}, "minotari": {"usd": evil}}, "USD") is None + + +class TestCoinGeckoClient: + def _resp(self, status=200, payload=None): + r = MagicMock() + r.status_code = status + r.json.return_value = payload if payload is not None else {} + return r + + def test_fetches_both_coins_over_tor(self): + c = CoinGeckoClient("USD", tor_proxy="socks5h://t:9050") + payload = {"monero": {"usd": 333.97}, "minotari": {"usd": 0.0004}} + with patch( + "mining_dashboard.service.price_feed.requests.get", + return_value=self._resp(200, payload), + ) as g: + assert c.fetch() == {"xmr": 333.97, "tari": 0.0004} + # routed through the Tor proxy, asking for both coin ids in the operator's currency + assert g.call_args.kwargs["proxies"] == { + "http": "socks5h://t:9050", + "https": "socks5h://t:9050", + } + assert g.call_args.kwargs["params"] == { + "ids": "monero,minotari", + "vs_currencies": "usd", + } + + def test_non_200_is_silent_none(self): + c = CoinGeckoClient("USD") + with patch( + "mining_dashboard.service.price_feed.requests.get", return_value=self._resp(429) + ): + assert c.fetch() is None + + def test_network_error_is_silent_none(self): + c = CoinGeckoClient("USD") + with patch( + "mining_dashboard.service.price_feed.requests.get", + side_effect=requests.RequestException("offline"), + ): + assert c.fetch() is None + + def test_non_alphabetic_currency_never_dials_out(self): + # `currency` is a free-form display label elsewhere (and dashboard-committable, #504); + # only a plain alphabetic code may leave the host as a query parameter β€” anything else + # must mean NO request at all, not a sanitized one. + for label in ("US Dollar$", "usd&x=exfil", "a" * 6, ""): + c = CoinGeckoClient(label, tor_proxy="socks5h://t:9050") + with patch("mining_dashboard.service.price_feed.requests.get") as g: + assert c.fetch() is None + g.assert_not_called() + + +class _FakeClient: + def __init__(self, prices=None, currency="USD"): + self.prices = prices + self.currency = currency + self.calls = 0 + + def fetch(self): + self.calls += 1 + return self.prices + + +class TestPriceFeed: + def test_disabled_never_calls_and_returns_none(self): + c = _FakeClient({"xmr": 300.0, "tari": 0.0004}) + pf = PriceFeed(c, enabled=False) + assert pf.maybe_fetch(1000) is None + assert c.calls == 0 + + def test_enabled_returns_prices_with_stamp(self): + pf = PriceFeed(_FakeClient({"xmr": 300.0, "tari": 0.0004}), enabled=True) + assert pf.maybe_fetch(1000) == { + "xmr": 300.0, + "tari": 0.0004, + "currency": "USD", + "fetched_at": 1000, + } + + def test_throttles_to_interval(self): + c = _FakeClient({"xmr": 300.0, "tari": 0.0004}) + pf = PriceFeed(c, enabled=True, interval=900) + pf.maybe_fetch(1000) + pf.maybe_fetch(1000 + 600) # within window -> cached, no network + assert c.calls == 1 + pf.maybe_fetch(1000 + 901) # past window -> network again + assert c.calls == 2 + + def test_failed_fetch_keeps_previous_prices(self): + c = _FakeClient({"xmr": 300.0, "tari": 0.0004}) + pf = PriceFeed(c, enabled=True, interval=0) + pf.maybe_fetch(1000) + c.prices = None # feed goes dark + out = pf.maybe_fetch(2000) + # a blip must not drop the last good prices β€” the UI shows their age instead + assert out["xmr"] == 300.0 + assert out["fetched_at"] == 1000 + + def test_no_result_before_first_success(self): + pf = PriceFeed(_FakeClient(None), enabled=True, interval=0) + assert pf.maybe_fetch(1000) is None diff --git a/build/dashboard/tests/web/test_views.py b/build/dashboard/tests/web/test_views.py index 05cc3930..b37e548e 100644 --- a/build/dashboard/tests/web/test_views.py +++ b/build/dashboard/tests/web/test_views.py @@ -2071,16 +2071,23 @@ def _worker(self, name, watts=None, hs=1000, active_pool="3333"): "rigforge": rf, } - def _energy(self, monkeypatch, workers, energy=None, descriptors=None): + def _energy(self, monkeypatch, workers, energy=None, descriptors=None, prices=None): from mining_dashboard.web import views monkeypatch.setattr( views.config, "DASHBOARD_ENERGY", - energy or {"cost_per_kwh": 0.0, "xmr_price": 0.0, "tari_price": 0.0, "currency": "USD"}, + { + "cost_per_kwh": 0.0, + "xmr_price": 0.0, + "tari_price": 0.0, + "currency": "USD", + "price_feed": False, + **(energy or {}), + }, ) monkeypatch.setattr(views.config, "DASHBOARD_WORKERS", descriptors or []) - return build_energy(workers) + return build_energy(workers, prices) def test_no_power_anywhere_is_unavailable(self, monkeypatch): got = self._energy(monkeypatch, [self._worker("r1"), self._worker("r2")]) @@ -2133,6 +2140,45 @@ def test_prices_pass_through(self, monkeypatch): assert got["xmr_price"] == 150.0 assert got["tari_price"] == 2.5 assert got["currency"] == "EUR" + # Static config prices: the calculator says so (#520 β€” a fiat figure is never unattributed). + assert got["price_source"] == {"feed": False, "live": False, "age_sec": None} + + def test_live_feed_prices_replace_static(self, monkeypatch): + # Feed on + a fetch landed: live prices stand in for the static numbers, with their age. + now = time.time() + got = self._energy( + monkeypatch, + [self._worker("r1", watts=100)], + energy={"xmr_price": 150.0, "tari_price": 2.5, "price_feed": True}, + prices={"xmr": 333.97, "tari": 0.0004, "currency": "USD", "fetched_at": now - 60}, + ) + assert got["xmr_price"] == 333.97 + assert got["tari_price"] == 0.0004 + assert got["price_source"]["feed"] is True + assert got["price_source"]["live"] is True + assert 59 <= got["price_source"]["age_sec"] <= 62 + + def test_feed_waiting_falls_back_to_static(self, monkeypatch): + # Feed on but no fetch yet (Tor down / first minutes): static prices stand, honestly labeled. + got = self._energy( + monkeypatch, + [self._worker("r1", watts=100)], + energy={"xmr_price": 150.0, "price_feed": True}, + prices=None, + ) + assert got["xmr_price"] == 150.0 + assert got["price_source"] == {"feed": True, "live": False, "age_sec": None} + + def test_feed_off_ignores_stray_prices(self, monkeypatch): + # A prices payload with the feed off must not override the operator's static numbers. + got = self._energy( + monkeypatch, + [self._worker("r1", watts=100)], + energy={"xmr_price": 150.0}, + prices={"xmr": 333.97, "tari": 0.0004, "currency": "USD", "fetched_at": time.time()}, + ) + assert got["xmr_price"] == 150.0 + assert got["price_source"]["live"] is False class TestBuildWorkerDetail: diff --git a/config.reference.json b/config.reference.json index 47c0b8a5..6379f07c 100644 --- a/config.reference.json +++ b/config.reference.json @@ -92,6 +92,7 @@ "energy": { "cost_per_kwh": 0, "currency": "USD", + "price_feed": false, "tari_price": 0, "xmr_price": 0 }, diff --git a/docs/configuration.md b/docs/configuration.md index 88239c2f..4e674f88 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -148,9 +148,10 @@ control channel will commit, are unaffected either way. | `workers.api_port` | `8080` | TCP port the worker xmrig API listens on. Change only if your miners expose the API on a non-standard port. | | `workers.list` | `[]` | Per-worker overrides for the worker-API probe, a list of `{name, host?, port?, token?, watts?, control_port?}` objects. Only needed when one rig doesn't match the fleet defaults above β€” a different API port, an API on another interface (NAT / multi-homed), or its own token. Each field is optional bar `name` (the rig's stratum name). **Merge rule:** a per-worker field beats the fleet default (`workers.api_port`, `workers.api_auth`/`workers.api_token`), which beats the built-in default; unlisted rigs inherit the fleet defaults untouched. A per-worker `token` forces token-auth for that one rig, whatever the fleet `api_auth` mode. `watts` is a manual power-draw estimate for the [energy calculator](dashboard.md#energy--profit), used only for a rig whose enriched feed reports no measured watts. `control_port` (default `8082`) is the rig's writable control API port, used by [Worker Inspect](dashboard.md#worker-inspect) to push config changes β€” set it alongside `host` + `token` to make a rig editable. Matched by `name` first, then by connecting IP against an operator-set `host`; duplicate names β†’ first-declared wins, so a renamed rig needs its entry updated. `host` **must** be operator-set here and is never derived from a miner-advertised value: the dashboard never sends a configured token to a miner-controlled host ([SSRF guard](workers.md#per-worker-overrides)). The standard fleet needs no entries. See [Connecting Miners β€Ί Per-worker overrides](workers.md#per-worker-overrides). `dashboard.workers` is a deprecated alias for this key (#506): still read if `workers.list` is unset, but setting both is refused at apply, and the old location is removed in v1.9. | | `dashboard.energy.cost_per_kwh` | `0` _(off)_ | Your electricity price per kWh, for the dashboard's [energy & profit](dashboard.md#energy--profit) tab. `0` or unset shows fleet power draw and efficiency but hides the cost and profit math. Any positive number adds power cost per day/month/year; combine with `xmr_price` for net profit. In the `currency` label below. | -| `dashboard.energy.xmr_price` | `0` _(off)_ | The fiat price of 1 XMR, in your `currency`, for the net-profit figure (`P2Pool XMR earnings Γ— this βˆ’ power cost`). `0` or unset hides net profit (energy cost still shows if `cost_per_kwh` is set). **No price feed ships** β€” fetching an exchange rate is a clearnet request this stack avoids ([Privacy β€Ί Runtime egress](privacy.md#runtime-egress)) β€” so you supply the price yourself. This is the base gate for net profit: it's P2Pool XMR only unless `tari_price` is also set (#520). XvB (raffle status, not a per-day income estimate) is always excluded. | -| `dashboard.energy.tari_price` | `0` _(off)_ | The fiat price of 1 XTM, in your `currency` (#520). Once both this and `xmr_price` are set, net profit folds in the estimated Tari merge-mining revenue (same what-if Tari/day estimate the Tari tab shows) β€” the card's heading and Net/day tooltip say "P2Pool + Tari" so the figure is never silently P2Pool-only. `0` or unset (or Tari not currently merge-mining) keeps net profit P2Pool XMR only. Same no-price-feed reasoning as `xmr_price` β€” you supply it yourself. | -| `dashboard.energy.currency` | `USD` | Display label for `cost_per_kwh`, `xmr_price` and `tari_price` (e.g. `USD`, `EUR`). A label only β€” no conversion happens, so set all prices in the same currency. | +| `dashboard.energy.xmr_price` | `0` _(off)_ | The fiat price of 1 XMR, in your `currency`, for the net-profit figure (`P2Pool XMR earnings Γ— this βˆ’ power cost`) and the β‰ˆ-fiat rows on the earnings card. `0` or unset hides net profit (energy cost still shows if `cost_per_kwh` is set). By default no price is fetched β€” fetching an exchange rate is network egress this stack doesn't do unbidden β€” so you supply the price yourself, or opt into the Tor-routed `price_feed` below. This is the base gate for net profit: it's P2Pool XMR only unless a Tari price is also known (#520). XvB (raffle status, not a per-day income estimate) is always excluded. | +| `dashboard.energy.tari_price` | `0` _(off)_ | The fiat price of 1 XTM, in your `currency` (#520). Once both this and `xmr_price` are set, net profit folds in the estimated Tari merge-mining revenue (same what-if Tari/day estimate the Tari tab shows) β€” the card's heading and Net/day tooltip say "P2Pool + Tari" so the figure is never silently P2Pool-only. `0` or unset (or Tari not currently merge-mining) keeps net profit P2Pool XMR only. Supplied by you, or fetched live via `price_feed` below. | +| `dashboard.energy.price_feed` | `false` | Fetch the XMR and XTM spot prices live from CoinGecko instead of the static numbers above (#520). **Opt-in, and always over Tor** (`socks5h`, the same route as the update check) β€” CoinGecko sees a Tor exit, never your IP ([Privacy β€Ί Runtime egress](privacy.md#runtime-egress)). Fetches both prices in your `currency` every 15 minutes; until the first fetch lands (and on any failure) the static `xmr_price` / `tari_price` stand, and the card's `Prices:` line always states which source is in use and how fresh it is. | +| `dashboard.energy.currency` | `USD` | Display label for `cost_per_kwh`, `xmr_price` and `tari_price` (e.g. `USD`, `EUR`) β€” and, with `price_feed` on, the currency the live prices are fetched in (any currency CoinGecko quotes; an unsupported label makes the fetch fail silently and the static prices stand). No conversion happens between the static fields, so set them all in the same currency. | | `healthchecks.ping_url` | _(blank)_ | The full ping URL from Healthchecks.io (e.g. `https://hc-ping.com/`) β€” the optional [dead-man's switch](monitoring.md) that alerts you when your host stops responding. **Setting it turns the monitor on; blank keeps it off.** Always pinged over Tor (every 60s), so it must be Tor-reachable (see [Monitoring β€Ί Privacy note](monitoring.md#privacy-note)). Treated as a secret β€” stored in the owner-only `.env`. | | `telegram.enabled` | `false` | Push operational alerts (node down/recovered, worker offline/back, sync finished) to Telegram. Off by default. Requires `bot_token` + `chat_id` to actually send. Full walkthrough: [Telegram Bot](telegram.md). | | `telegram.bot_token` | `""` | Your BotFather bot token. A secret β€” stored owner-only in `.env`, git-ignored, and never logged. Get one from [@BotFather](https://t.me/BotFather). | diff --git a/docs/dashboard.md b/docs/dashboard.md index 26ec4a0c..f6035e5f 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -396,15 +396,28 @@ but leave `xmr_price` unset and you get the energy cost but no net. Net profit s what-if hashrate as the other tabs (power draw does not β€” it is the measured fleet), and it goes red when power costs more than it earns. -Net profit counts **P2Pool XMR**, plus **Tari** merge-mining earnings once you also set -`tari_price` (Tari's contribution uses the same what-if Tari/day estimate the Tari tab already -shows). Leave `tari_price` at `0`/unset and net profit is P2Pool XMR only β€” the card's heading and -the Net/day tooltip say exactly which figure you're looking at, so it's never silently partial. -**XvB stays excluded** either way: it's raffle status, not a clean per-day income estimate, so -folding it in would mean guessing. **No price feed ships for either coin:** fetching an exchange -rate is a clearnet request this privacy-first stack avoids, so you supply both prices yourself (see -[Privacy β€Ί Runtime egress](privacy.md#runtime-egress)). An opt-in, Tor-routed price feed is a -possible follow-up, not implemented here. +Net profit counts **P2Pool XMR**, plus **Tari** merge-mining earnings once a Tari price is also +known (Tari's contribution uses the same what-if Tari/day estimate the Tari tab already shows). +With no Tari price, net profit is P2Pool XMR only β€” the card's heading and the Net/day tooltip say +exactly which figure you're looking at, so it's never silently partial. **XvB stays excluded** +either way: it's raffle status, not a clean per-day income estimate, so folding it in would mean +guessing. + +Prices come from one of two places, and the card always says which: + +- **Static (default):** you type `xmr_price` / `tari_price` into config.json yourself. No network + request is made β€” the default posture stays free of price-feed egress. +- **Live feed (opt-in):** set `dashboard.energy.price_feed: true` and the dashboard fetches both + prices from CoinGecko every 15 minutes, in your `currency` β€” **over Tor**, like every other + dashboard egress, so CoinGecko sees a Tor exit and never your IP (see + [Privacy β€Ί Runtime egress](privacy.md#runtime-egress)). The static numbers remain the fallback + until the first fetch lands; on a failed fetch the last good prices stand and their age is shown. + +Once a price is known (either way), the earnings card also grows **β‰ˆ-fiat rows**: the Monero tab +shows the fiat value of the XMR/day/month/year estimates, the Tari tab of the per-block reward and +XTM/day average, and the XvB tab a fiat mirror of the tier comparison. A `Prices:` line at the foot +of the card states the exact prices in use and their source β€” live feed (with age) or config.json β€” +so no fiat figure is ever unattributed. ### Payout confirmation diff --git a/docs/privacy.md b/docs/privacy.md index 5423ae2b..816f037c 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -81,6 +81,7 @@ What the running stack sends to the internet, connection by connection. | **Caddy** TLS (dashboard HTTPS) | local only | β€” | n/a β€” `tls internal`, **no ACME / no external CA** | on | clean (no egress) | | **Telegram** bot (#121) | `api.telegram.org` | nothing about you β€” Telegram sees a **Tor exit**, not your IP | βœ… **always** Tor (`socks5h`, #340) | **off** | opt-in; both the alert sends and the command poll ride Tor | | Dashboard **Healthchecks** ping (#79) | `hc-ping.com` (or self-hosted) | nothing about you β€” the endpoint sees a **Tor exit**, not your IP | βœ… **always** Tor (`socks5h`) | opt-in (set `healthchecks.ping_url`; off until set) | the ping URL must be Tor-reachable (hosted, public, or an onion self-hosted instance) β€” there is no clearnet mode | +| Dashboard **price feed** (#520) | `api.coingecko.com` | nothing about you β€” CoinGecko sees a **Tor exit**, not your IP | βœ… **always** Tor (`socks5h`) | **off** | opt-in (`dashboard.energy.price_feed: true`); fetches the XMR + XTM spot prices every 15 min; fails silently, static config prices are the fallback | | **Webhook / ntfy** alert sinks (#380) | your configured URLs | alert texts; the endpoint sees a **Tor exit**, not your IP | βœ… Tor (`socks5h`) by default | opt-in (set `notifications.webhooks` / `notifications.ntfy.url`; off until set) | `notifications.tor: false` is the LAN carve-out (Tor exits can't reach private addresses) β€” with it, a **clearnet** endpoint sees your host IP on every alert | `socks5h` (used for the XvB stats fetch) routes DNS resolution through Tor too, so the hostname isn't diff --git a/pithead b/pithead index dfe997e7..24ddf26a 100755 --- a/pithead +++ b/pithead @@ -2423,15 +2423,15 @@ validate_worker_endpoints() { # Validate the dashboard.energy block for the energy/profit calculator (#260). Like the worker # descriptors, this renders nothing to .env β€” the dashboard reads it off the read-only config.json # bind mount β€” so validation exists only to fail an apply loudly on a typo. Prices are operator-set -# (no price feed ships: fetching one is a clearnet egress this stack avoids), so both must be -# non-negative numbers and the currency a short label. +# non-negative numbers and the currency a short label; price_feed (#520) opts into fetching both +# prices live from CoinGecko over Tor instead (the static numbers stay as the fallback). validate_energy_config() { local en_err en_err=$(jq -r ' (.dashboard.energy // {}) as $e - | if ($e | type) != "object" then "dashboard.energy must be an object {cost_per_kwh, currency?, xmr_price?, tari_price?}." - elif (($e | keys) - ["cost_per_kwh", "currency", "xmr_price", "tari_price"]) != [] - then "dashboard.energy has an unknown key (\(($e | keys) - ["cost_per_kwh", "currency", "xmr_price", "tari_price"] | join(", "))). Only cost_per_kwh, currency, xmr_price and tari_price are allowed." + | if ($e | type) != "object" then "dashboard.energy must be an object {cost_per_kwh, currency?, xmr_price?, tari_price?, price_feed?}." + elif (($e | keys) - ["cost_per_kwh", "currency", "xmr_price", "tari_price", "price_feed"]) != [] + then "dashboard.energy has an unknown key (\(($e | keys) - ["cost_per_kwh", "currency", "xmr_price", "tari_price", "price_feed"] | join(", "))). Only cost_per_kwh, currency, xmr_price, tari_price and price_feed are allowed." elif ($e | has("cost_per_kwh")) and (($e.cost_per_kwh | type) != "number" or $e.cost_per_kwh < 0) then "dashboard.energy.cost_per_kwh must be a non-negative number (your electricity price per kWh; 0 or unset hides the profit math)." elif ($e | has("xmr_price")) and (($e.xmr_price | type) != "number" or $e.xmr_price < 0) @@ -2440,6 +2440,8 @@ validate_energy_config() { then "dashboard.energy.tari_price must be a non-negative number (the fiat price of 1 XTM in your currency; 0 or unset excludes Tari from net profit)." elif ($e | has("currency")) and (($e.currency | type) != "string" or ($e.currency | test("^[!-~]{1,128}$") | not)) then "dashboard.energy.currency must be a short currency label (e.g. USD, EUR)." + elif ($e | has("price_feed")) and (($e.price_feed | type) != "boolean") + then "dashboard.energy.price_feed must be true or false (fetch live XMR/XTM prices from CoinGecko over Tor; default false, no clearnet egress)." else empty end' "$CONFIG_FILE" 2>/dev/null) [ -z "$en_err" ] || error "$en_err" } diff --git a/tests/stack/run.sh b/tests/stack/run.sh index 055e8dbf..2b33b589 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -2713,13 +2713,17 @@ en_case '{"cost_per_kwh":-1}' "negative cost_per_kwh" "dashboard.energy.cost_per en_case '{"xmr_price":"lots"}' "non-number xmr_price" "dashboard.energy.xmr_price" en_case '{"tari_price":-2}' "negative tari_price (#520)" "dashboard.energy.tari_price" en_case '{"currency":"US Dollars"}' "unsafe currency label" "dashboard.energy.currency" +# price_feed (#520): boolean only β€” a truthy string must not silently opt into network egress. +en_case '{"price_feed":"yes"}' "non-boolean price_feed (#520)" "dashboard.energy.price_feed" # Closed schema (#33 hardening): the validator rejects any key outside {cost_per_kwh, currency, -# xmr_price, tari_price} β€” defense in depth beneath the control gate's own unknown-path refusal. +# xmr_price, tari_price, price_feed} β€” defense in depth beneath the control gate's own +# unknown-path refusal. en_case '{"cost_per_kwh":0.1,"__evil":{"x":1}}' "unknown dashboard.energy subkey" "dashboard.energy has an unknown key" -# A valid energy block (prices + per-worker watts) applies; like workers[], nothing reaches .env. +# A valid energy block (prices + feed opt-in + per-worker watts) applies; like workers[], nothing +# reaches .env. seed_env -printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T"}, "p2pool":{"pool":"main"}, "dashboard":{"secure":true,"host":"box.lan","energy":{"cost_per_kwh":0.18,"xmr_price":150,"tari_price":2.5,"currency":"EUR"},"workers":[{"name":"rig1","watts":142}]} }\n' "$WALLET" >"$V/config.json" +printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T"}, "p2pool":{"pool":"main"}, "dashboard":{"secure":true,"host":"box.lan","energy":{"cost_per_kwh":0.18,"xmr_price":150,"tari_price":2.5,"currency":"EUR","price_feed":true},"workers":[{"name":"rig1","watts":142}]} }\n' "$WALLET" >"$V/config.json" out="$(cd "$V" && PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)" assert_rc "valid dashboard.energy applies" "$?" "0" From b8bec68c92db266f7fbeff0565933cf3aa17dd3c Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Sat, 18 Jul 2026 23:40:53 -0500 Subject: [PATCH 2/2] =?UTF-8?q?release:=20prep=20v1.9.1=20=E2=80=94=20live?= =?UTF-8?q?=20price=20feed=20(#651/#646)=20(#653)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VERSION + pyproject 1.9.1 lockstep, uv.lock regenerated, CHANGELOG entry for the price feed (which shipped without one). Co-authored-by: Claude Fable 5 --- CHANGELOG.md | 14 ++++++++++++++ VERSION | 2 +- build/dashboard/pyproject.toml | 2 +- build/dashboard/uv.lock | 2 +- 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eea9e39..06ed1a8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,20 @@ per the process in [`docs/releasing.md`](docs/releasing.md). ## [Unreleased] +## [1.9.1] - 2026-07-19 + +### Added + +- **Opt-in live XMR/XTM price feed for the energy calculator (#651 β€” the auto half #520 + deferred).** `dashboard.energy.price_feed` (default off) fetches both spot prices from + CoinGecko in your configured currency β€” always over Tor, same route as every other stack + egress; no clearnet branch exists. The earnings card states which price it is using (live + with age, or static from config.json β€” the static numbers stay the fallback), and the + Monero/Tari/XvB tabs gain fiat mirrors of their coin figures. The new egress appears in the + egress-posture panel, the Stack Topology, and the privacy table. Hardened against a hostile + price response (non-finite values rejected) and a crafted currency label (plainly alphabetic + or no request leaves the host). + ## [1.9.0] - 2026-07-18 **Stratum link security.** v1.9 locks the last cleartext link β€” miner ↔ stack diff --git a/VERSION b/VERSION index abb16582..ee672d89 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.9.0 \ No newline at end of file +1.9.1 \ No newline at end of file diff --git a/build/dashboard/pyproject.toml b/build/dashboard/pyproject.toml index f8fe39de..84c11ea8 100644 --- a/build/dashboard/pyproject.toml +++ b/build/dashboard/pyproject.toml @@ -7,7 +7,7 @@ name = "mining-dashboard" # Keep in lockstep with the top-level VERSION file β€” the single source of truth for the stack version # (#44). A shell test (tests/stack/run.sh) fails if these drift; the dashboard *displays* the version # from VERSION (baked in as PITHEAD_VERSION, #58), so this is packaging metadata only. -version = "1.9.0" +version = "1.9.1" description = "Monitoring dashboard and XvB switching engine for Pithead" readme = "README.md" requires-python = ">=3.11" diff --git a/build/dashboard/uv.lock b/build/dashboard/uv.lock index c10e1064..37e03d01 100644 --- a/build/dashboard/uv.lock +++ b/build/dashboard/uv.lock @@ -782,7 +782,7 @@ wheels = [ [[package]] name = "mining-dashboard" -version = "1.9.0" +version = "1.9.1" source = { editable = "." } dependencies = [ { name = "aiofiles" },