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/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/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/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`
- <${XvbTierBlock} calc=${xvb} hr=${hr} coeffDay=${e.coeff_day} />
+ <${XvbTierBlock} calc=${xvb} hr=${hr} coeffDay=${e.coeff_day} energy=${energy} />
`
: 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/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/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/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" },
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"