Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.9.0
1.9.1
14 changes: 9 additions & 5 deletions build/dashboard/mining_dashboard/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
}


Expand Down
23 changes: 23 additions & 0 deletions build/dashboard/mining_dashboard/service/data_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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}")
Expand Down
18 changes: 18 additions & 0 deletions build/dashboard/mining_dashboard/service/egress.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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,
},
],
},
{
Expand Down Expand Up @@ -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"],
)


Expand Down Expand Up @@ -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}``.

Expand All @@ -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
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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"],
)
108 changes: 108 additions & 0 deletions build/dashboard/mining_dashboard/service/price_feed.py
Original file line number Diff line number Diff line change
@@ -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
Loading