Skip to content

Commit 46db88a

Browse files
authored
feat(dashboard): fold Tari revenue into the energy/profit net calculation (#520) (#614)
dashboard.energy.tari_price adds Tari merge-mining earnings to the Energy tab's net profit, using the same what-if Tari/day estimate the Tari tab already shows. XvB stays excluded (raffle status, no clean per-day estimate). The net-profit heading and tooltip now say exactly what's included so the figure is never silently P2Pool-only.
1 parent fbc85bc commit 46db88a

15 files changed

Lines changed: 201 additions & 53 deletions

File tree

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ per the process in [`docs/releasing.md`](docs/releasing.md).
1111

1212
## [Unreleased]
1313

14+
### Added
15+
16+
- **Energy calculator: Tari revenue in net profit (#520).** `dashboard.energy.tari_price` (fiat
17+
price of 1 XTM, default `0`/off) folds the estimated Tari merge-mining revenue into the Energy
18+
tab's net profit once it's set alongside the existing `xmr_price` — previously net profit counted
19+
P2Pool XMR only, undercounting a Tari merge-miner's actual revenue. Uses the same what-if Tari/day
20+
estimate the Tari tab already shows, no new estimate invented. XvB stays excluded (raffle status,
21+
not a clean per-day income estimate). The card's heading and Net/day tooltip now say exactly
22+
what's counted ("P2Pool + Tari, after power" vs "P2Pool XMR only, after power") so the figure is
23+
never silently partial. Static, operator-supplied price only — an opt-in Tor-routed price feed is
24+
a deferred follow-up, not implemented here (fetching one is a clearnet egress this privacy-first
25+
stack avoids).
26+
1427
## [1.7.0] - 2026-07-17
1528

1629
The **Config UX & telemetry** cycle. Config editing becomes humane — worker

build/dashboard/mining_dashboard/config/config.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,10 @@ def load_energy_config(path=None):
315315
``xmr_price`` — operator-supplied fiat price of 1 XMR (0/unset ⇒ net profit hidden). No price
316316
feed ships: fetching one is a clearnet egress this privacy-first stack avoids,
317317
so the operator supplies it, in the same ``currency`` as ``cost_per_kwh``.
318-
``currency`` — display label for both figures (e.g. USD, EUR). Label only — no conversion.
318+
``tari_price`` — operator-supplied fiat price of 1 XTM (#520; 0/unset ⇒ net profit counts
319+
P2Pool XMR only). Same no-price-feed reasoning as ``xmr_price``; folds Tari's
320+
merge-mined earnings into net profit once both this and ``xmr_price`` are set.
321+
``currency`` — display label for all figures (e.g. USD, EUR). Label only — no conversion.
319322
"""
320323
try:
321324
with open(path or HOST_CONFIG_PATH) as f:
@@ -336,6 +339,7 @@ def _nonneg(v):
336339
return {
337340
"cost_per_kwh": _nonneg(raw.get("cost_per_kwh")),
338341
"xmr_price": _nonneg(raw.get("xmr_price")),
342+
"tari_price": _nonneg(raw.get("tari_price")),
339343
"currency": currency,
340344
}
341345

build/dashboard/mining_dashboard/web/static/components.mjs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -566,12 +566,21 @@ class EarningsCard extends Component {
566566
// Energy & profit tab body (#260). Fleet power draw + efficiency (always, when any power is known),
567567
// then energy cost once an electricity price is set, then net profit once an XMR price is also set —
568568
// each layer appears only when its inputs exist, so the operator never sees a fabricated figure.
569-
// `est` is the earnings for the shared what-if hashrate; the client does the kWh/cost/net math.
569+
// Setting a Tari price too (#520) folds the Tari merge-mining estimate into gross; the heading and
570+
// the Net/day tooltip say exactly what's counted so a Tari merge-miner's net is never silently
571+
// P2Pool-only. `est` is the earnings for the shared what-if hashrate; the client does the
572+
// kWh/cost/net math.
570573
function EnergyPanel({ energy, est }) {
571574
const en = computeEnergy(energy, est);
572575
const cur = energy.currency;
573576
const haveCost = energy.cost_per_kwh > 0;
574577
const haveNet = haveCost && energy.xmr_price > 0;
578+
// Honest label (#520): say exactly what gross counts so the net figure is never silently
579+
// partial. XvB never appears here — raffle status, not a clean per-day estimate.
580+
const netLabel = en.includesTari ? "P2Pool + Tari, after power" : "P2Pool XMR only, after power";
581+
const netTitle = en.includesTari
582+
? "P2Pool XMR + Tari (merge-mined) earnings at your set prices, minus power cost. Excludes XvB (raffle status, not a per-day income estimate)."
583+
: "P2Pool XMR earnings at your XMR price, minus power cost. Excludes Tari (set dashboard.energy.tari_price to include it) and XvB (raffle status, not a per-day income estimate).";
575584
return html`
576585
<div class="stat-grid">
577586
<${StatCard} label="Fleet Power" value=${formatUnit(energy.total_watts, "W")}
@@ -590,7 +599,7 @@ function EnergyPanel({ energy, est }) {
590599
${
591600
haveCost
592601
? html`
593-
<h4 class="text-small mt-2">Cost${haveNet ? " & Net Profit" : ""} (${cur})</h4>
602+
<h4 class="text-small mt-2">Cost${haveNet ? ` & Net Profit${netLabel}` : ""} (${cur})</h4>
594603
<div class="stat-grid">
595604
<${StatCard} label="Power cost / day" value=${formatFiat(en.costDay, cur)} />
596605
<${StatCard} label="Power cost / month" value=${formatFiat(en.costMonth, cur)} />
@@ -600,7 +609,7 @@ function EnergyPanel({ energy, est }) {
600609
? html`
601610
<${StatCard} label="Net / day" value=${formatFiat(en.netDay, cur)}
602611
cls=${en.netDay !== null && en.netDay < 0 ? "c-bad" : "text-accent"}
603-
title="P2Pool XMR earnings at your XMR price, minus power cost. Excludes Tari and XvB." />
612+
title=${netTitle} />
604613
<${StatCard} label="Net / month" value=${formatFiat(en.netMonth, cur)}
605614
cls=${en.netMonth !== null && en.netMonth < 0 ? "c-bad" : "text-accent"} />
606615
<${StatCard} label="Net / year" value=${formatFiat(en.netYear, cur)}

build/dashboard/mining_dashboard/web/static/logic.mjs

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -288,13 +288,20 @@ export function formatXtm(xtm) {
288288
return formatCoin(xtm, "XTM");
289289
}
290290

291-
// Energy & profit (#260). Turns the server's measured fleet watts + operator prices into
292-
// kWh / cost / net over day·month·year for a what-if hashrate. Power draw is measured (does NOT
293-
// scale with the what-if hashrate — it's the real fleet), so only the earnings side scales:
294-
// net = gross(what-if XMR/day × xmr_price) − cost(kWh × cost_per_kwh).
295-
// `est` is the already-computed earnings for this what-if hashrate (computeEarnings). Any figure
296-
// whose inputs are missing comes back null so the card shows "—" rather than a bogus number:
297-
// cost needs cost_per_kwh > 0; net additionally needs xmr_price > 0 and a valid XMR estimate.
291+
// Energy & profit (#260, Tari revenue #520). Turns the server's measured fleet watts + operator
292+
// prices into kWh / cost / net over day·month·year for a what-if hashrate. Power draw is measured
293+
// (does NOT scale with the what-if hashrate — it's the real fleet), so only the earnings side
294+
// scales: net = gross − cost(kWh × cost_per_kwh), where
295+
// gross = (what-if XMR/day × xmr_price) + (what-if Tari/day × tari_price, when priced).
296+
// `est` is the already-computed earnings for this what-if hashrate (computeEarnings; `est.tariDay`
297+
// is the same Tari/day estimate the Tari tab already shows — no separate estimate invented here).
298+
// XvB stays excluded from gross: it's a raffle status (xvbTierComparison), not a clean per-day
299+
// credited estimate — guessing one would fabricate a figure.
300+
// Any figure whose inputs are missing comes back null so the card shows "—" rather than a bogus
301+
// number: cost needs cost_per_kwh > 0; net additionally needs xmr_price > 0 and a valid XMR
302+
// estimate — Tari only ever ADDS to that base (mirrors the existing xmr_price-gates-net contract,
303+
// #520 scope: no tari-price-only net). `includesTari` tells the UI which figure it got, so the
304+
// net-profit label is never silently partial (#520's honesty requirement).
298305
export function computeEnergy(energy, est) {
299306
const blank = {
300307
kwhDay: null,
@@ -306,18 +313,21 @@ export function computeEnergy(energy, est) {
306313
netDay: null,
307314
netMonth: null,
308315
netYear: null,
316+
includesTari: false,
309317
};
310318
if (!energy || !energy.available || !(energy.total_watts > 0)) return blank;
311319
const kwhDay = (energy.total_watts / 1000) * 24;
312320
const price = energy.cost_per_kwh;
313321
const haveCost = Number.isFinite(price) && price > 0;
314322
const costDay = haveCost ? kwhDay * price : null;
315323
const xmrPrice = energy.xmr_price;
316-
// Gross fiat/day from P2Pool XMR only (Tari lumpy/priced-separately, XvB is raffle status).
317-
const grossDay =
318-
Number.isFinite(xmrPrice) && xmrPrice > 0 && est && Number.isFinite(est.day)
319-
? est.day * xmrPrice
320-
: null;
324+
const tariPrice = energy.tari_price;
325+
const haveXmr = Number.isFinite(xmrPrice) && xmrPrice > 0 && est && Number.isFinite(est.day);
326+
const includesTari =
327+
haveXmr && Number.isFinite(tariPrice) && tariPrice > 0 && Number.isFinite(est.tariDay);
328+
const grossDay = haveXmr
329+
? est.day * xmrPrice + (includesTari ? est.tariDay * tariPrice : 0)
330+
: null;
321331
const netDay = grossDay !== null && costDay !== null ? grossDay - costDay : null;
322332
const span = (v, mult) => (v === null ? null : v * mult);
323333
return {
@@ -330,6 +340,7 @@ export function computeEnergy(energy, est) {
330340
netDay,
331341
netMonth: span(netDay, DAYS_PER_MONTH),
332342
netYear: span(netDay, DAYS_PER_YEAR),
343+
includesTari,
333344
};
334345
}
335346

build/dashboard/mining_dashboard/web/views.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -845,21 +845,25 @@ def build_workers(workers):
845845

846846

847847
# --------------------------------------------------------------------------------------
848-
# Energy & profit calculator (Issue #260): fleet power draw + efficiency, and — once the operator
849-
# sets an electricity price (and an XMR price) — the net profit after power. The server totals the
850-
# measured draw and publishes the prices; the client does the per-day/month/year arithmetic and the
851-
# net = gross − cost, scaling gross with the same what-if hashrate the earnings card already uses
852-
# (one source of truth, #61). Deliberately NO price feed: fetching one is a clearnet egress this
853-
# privacy-first stack avoids (#160), so both prices are operator-supplied.
848+
# Energy & profit calculator (Issue #260, Tari revenue #520): fleet power draw + efficiency, and —
849+
# once the operator sets an electricity price (and an XMR price) — the net profit after power.
850+
# Setting a Tari price too folds the estimated Tari merge-mining revenue into gross so a Tari
851+
# merge-miner's net profit isn't silently undercounted (P2Pool-only was the #520 bug). The server
852+
# totals the measured draw and publishes the prices; the client does the per-day/month/year
853+
# arithmetic and the net = gross − cost, scaling gross with the same what-if hashrate the earnings
854+
# card already uses (one source of truth, #61). Deliberately NO price feed for either coin: fetching
855+
# one is a clearnet egress this privacy-first stack avoids (#160) — an opt-in Tor-routed feed is
856+
# deferred, see #520 — so all prices are operator-supplied.
854857
# --------------------------------------------------------------------------------------
855858

856859
_ENERGY_DISCLAIMER = (
857860
"Power draw is measured (RAPL, 15s sample) or your per-worker estimate; a worker reporting "
858861
"neither is excluded and the fleet total is marked incomplete. kWh and cost extrapolate the "
859862
"current draw at a constant rate — a naive projection, not a metered bill. Net profit is "
860-
"P2Pool XMR earnings valued at your XMR price, minus power cost: it excludes Tari (lumpy solo "
861-
"merge-mining, priced separately) and XvB (raffle status, not income). Estimates, not "
862-
"guarantees."
863+
"P2Pool XMR earnings valued at your XMR price, plus Tari merge-mining earnings valued at your "
864+
"Tari price once dashboard.energy.tari_price is set (0/unset counts P2Pool XMR only) — minus "
865+
"power cost. XvB stays excluded: it's raffle status, not a clean per-day income estimate. "
866+
"Estimates, not guarantees."
863867
)
864868

865869

@@ -924,6 +928,7 @@ def build_energy(workers):
924928
"incomplete": incomplete,
925929
"cost_per_kwh": cfg["cost_per_kwh"],
926930
"xmr_price": cfg["xmr_price"],
931+
"tari_price": cfg["tari_price"],
927932
"currency": cfg["currency"],
928933
"per_worker": per_worker,
929934
"disclaimer": _ENERGY_DISCLAIMER,

build/dashboard/tests/config/test_config.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,15 +304,30 @@ def test_defaults_when_absent(self, tmp_path):
304304
assert self._load(tmp_path, {"dashboard": {}}) == {
305305
"cost_per_kwh": 0.0,
306306
"xmr_price": 0.0,
307+
"tari_price": 0.0,
307308
"currency": "USD",
308309
}
309310

310311
def test_valid_values_load(self, tmp_path):
311312
got = self._load(
312313
tmp_path,
313-
{"dashboard": {"energy": {"cost_per_kwh": 0.18, "xmr_price": 150, "currency": "EUR"}}},
314+
{
315+
"dashboard": {
316+
"energy": {
317+
"cost_per_kwh": 0.18,
318+
"xmr_price": 150,
319+
"tari_price": 2.5,
320+
"currency": "EUR",
321+
}
322+
}
323+
},
314324
)
315-
assert got == {"cost_per_kwh": 0.18, "xmr_price": 150.0, "currency": "EUR"}
325+
assert got == {
326+
"cost_per_kwh": 0.18,
327+
"xmr_price": 150.0,
328+
"tari_price": 2.5,
329+
"currency": "EUR",
330+
}
316331

317332
def test_invalid_values_degrade_to_defaults(self, tmp_path):
318333
got = self._load(
@@ -322,23 +337,25 @@ def test_invalid_values_degrade_to_defaults(self, tmp_path):
322337
"energy": {
323338
"cost_per_kwh": -1,
324339
"xmr_price": "expensive",
340+
"tari_price": -2,
325341
"currency": "has space",
326342
}
327343
}
328344
},
329345
)
330-
assert got == {"cost_per_kwh": 0.0, "xmr_price": 0.0, "currency": "USD"}
346+
assert got == {"cost_per_kwh": 0.0, "xmr_price": 0.0, "tari_price": 0.0, "currency": "USD"}
331347

332348
def test_non_object_energy_degrades_to_defaults(self, tmp_path):
333349
# A hand-edited `energy` that isn't an object mustn't crash — fall back to defaults.
334350
got = self._load(tmp_path, {"dashboard": {"energy": "nope"}})
335-
assert got == {"cost_per_kwh": 0.0, "xmr_price": 0.0, "currency": "USD"}
351+
assert got == {"cost_per_kwh": 0.0, "xmr_price": 0.0, "tari_price": 0.0, "currency": "USD"}
336352

337353
def test_missing_file_reads_defaults(self, tmp_path):
338354
from mining_dashboard.config.config import load_energy_config
339355

340356
assert load_energy_config(str(tmp_path / "absent.json")) == {
341357
"cost_per_kwh": 0.0,
342358
"xmr_price": 0.0,
359+
"tari_price": 0.0,
343360
"currency": "USD",
344361
}

build/dashboard/tests/frontend/components.test.mjs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,11 +243,42 @@ test('EarningsCard Energy tab shows cost then net as prices are set (#260)', ()
243243
assert.match(html, /Power cost \/ day/);
244244
assert.match(html, /set xmr_price/);
245245
assert.doesNotMatch(html, /Net \/ day/);
246-
// Both prices set → net profit appears.
246+
// Both prices set → net profit appears, labelled P2Pool-only since tari_price is still unset.
247247
s.energy.xmr_price = 150;
248248
html = renderApp({ state: s });
249249
assert.match(html, /Net \/ day/);
250250
assert.match(html, /Net \/ year/);
251+
assert.match(html, /P2Pool XMR only, after power/);
252+
assert.doesNotMatch(html, /P2Pool \+ Tari, after power/);
253+
});
254+
255+
test('EarningsCard Energy tab folds Tari into net profit once tari_price is set and Tari is merge-mining (#520)', () => {
256+
const s = clone();
257+
s.earnings.available = true;
258+
s.earnings.coeff_day = 1e-8;
259+
s.earnings.tari_available = true;
260+
s.earnings.tari_coeff_day = 1e-6;
261+
s.energy.cost_per_kwh = 0.2;
262+
s.energy.xmr_price = 150;
263+
s.energy.tari_price = 2;
264+
const html = renderApp({ state: s });
265+
assert.match(html, /Net \/ day/);
266+
assert.match(html, /P2Pool \+ Tari, after power/);
267+
assert.doesNotMatch(html, /P2Pool XMR only, after power/);
268+
});
269+
270+
test('EarningsCard Energy tab keeps the P2Pool-only label when tari_price is set but Tari is not merge-mining (#520)', () => {
271+
const s = clone();
272+
s.earnings.available = true;
273+
s.earnings.coeff_day = 1e-8;
274+
s.earnings.tari_available = false; // no Tari estimate to fold in
275+
s.energy.cost_per_kwh = 0.2;
276+
s.energy.xmr_price = 150;
277+
s.energy.tari_price = 2;
278+
const html = renderApp({ state: s });
279+
assert.match(html, /Net \/ day/);
280+
assert.match(html, /P2Pool XMR only, after power/);
281+
assert.doesNotMatch(html, /P2Pool \+ Tari, after power/);
251282
});
252283

253284
test('XvB comparison dropdown shows Expected/Cost/Net per tier, degrades on a stale estimate (#118)', () => {

build/dashboard/tests/frontend/fixtures/state.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,7 @@
594594
"incomplete": false,
595595
"cost_per_kwh": 0.0,
596596
"xmr_price": 0.0,
597+
"tari_price": 0.0,
597598
"currency": "USD",
598599
"per_worker": [
599600
{

build/dashboard/tests/frontend/logic.test.mjs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,9 +508,50 @@ test('computeEnergy: unavailable / zero watts returns all nulls', () => {
508508
const en = computeEnergy(bad, { day: 1 });
509509
assert.equal(en.kwhDay, null);
510510
assert.equal(en.netDay, null);
511+
assert.equal(en.includesTari, false);
511512
}
512513
});
513514

515+
// --- Tari revenue in net profit (#520) ---------------------------------------------------
516+
517+
test('computeEnergy: both prices set -> gross combines P2Pool XMR + Tari', () => {
518+
const en = computeEnergy(
519+
{ available: true, total_watts: 1000, cost_per_kwh: 0.2, xmr_price: 150, tari_price: 2 },
520+
{ day: 0.1, tariDay: 5 }, // 0.1 XMR/day, 5 XTM/day
521+
);
522+
// gross = 0.1*150 + 5*2 = 15 + 10 = 25; cost = 24*0.2 = 4.8; net = 20.2
523+
assert.ok(Math.abs(en.netDay - 20.2) < 1e-9);
524+
assert.equal(en.includesTari, true);
525+
});
526+
527+
test('computeEnergy: only xmr_price set -> P2Pool-only net, includesTari false', () => {
528+
const en = computeEnergy(
529+
{ available: true, total_watts: 1000, cost_per_kwh: 0.2, xmr_price: 150, tari_price: 0 },
530+
{ day: 0.1, tariDay: 5 }, // Tari estimate exists but is unpriced
531+
);
532+
// gross = 0.1*150 = 15 (Tari excluded); cost = 4.8; net = 10.2
533+
assert.ok(Math.abs(en.netDay - 10.2) < 1e-9);
534+
assert.equal(en.includesTari, false);
535+
});
536+
537+
test('computeEnergy: tari_price set but no xmr_price -> no net at all (xmr_price is the base gate)', () => {
538+
const en = computeEnergy(
539+
{ available: true, total_watts: 1000, cost_per_kwh: 0.2, xmr_price: 0, tari_price: 2 },
540+
{ day: 0.1, tariDay: 5 },
541+
);
542+
assert.equal(en.netDay, null);
543+
assert.equal(en.includesTari, false);
544+
});
545+
546+
test('computeEnergy: tari_price set but Tari not merge-mining (tariDay null) -> P2Pool-only', () => {
547+
const en = computeEnergy(
548+
{ available: true, total_watts: 1000, cost_per_kwh: 0.2, xmr_price: 150, tari_price: 2 },
549+
{ day: 0.1, tariDay: null },
550+
);
551+
assert.ok(Math.abs(en.netDay - 10.2) < 1e-9); // same as P2Pool-only case above
552+
assert.equal(en.includesTari, false);
553+
});
554+
514555
test('formatFiat: currency label, two decimals, keeps the sign', () => {
515556
assert.equal(formatFiat(12.5, 'USD'), 'USD 12.50');
516557
assert.equal(formatFiat(-3, 'EUR'), '-EUR 3.00');

build/dashboard/tests/web/test_views.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2021,7 +2021,7 @@ def _energy(self, monkeypatch, workers, energy=None, descriptors=None):
20212021
monkeypatch.setattr(
20222022
views.config,
20232023
"DASHBOARD_ENERGY",
2024-
energy or {"cost_per_kwh": 0.0, "xmr_price": 0.0, "currency": "USD"},
2024+
energy or {"cost_per_kwh": 0.0, "xmr_price": 0.0, "tari_price": 0.0, "currency": "USD"},
20252025
)
20262026
monkeypatch.setattr(views.config, "DASHBOARD_WORKERS", descriptors or [])
20272027
return build_energy(workers)
@@ -2066,10 +2066,16 @@ def test_prices_pass_through(self, monkeypatch):
20662066
got = self._energy(
20672067
monkeypatch,
20682068
[self._worker("r1", watts=100)],
2069-
energy={"cost_per_kwh": 0.2, "xmr_price": 150.0, "currency": "EUR"},
2069+
energy={
2070+
"cost_per_kwh": 0.2,
2071+
"xmr_price": 150.0,
2072+
"tari_price": 2.5,
2073+
"currency": "EUR",
2074+
},
20702075
)
20712076
assert got["cost_per_kwh"] == 0.2
20722077
assert got["xmr_price"] == 150.0
2078+
assert got["tari_price"] == 2.5
20732079
assert got["currency"] == "EUR"
20742080

20752081

0 commit comments

Comments
 (0)