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
4 changes: 4 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ regexTarget = "line"
# is still caught.
regexes = [
'''-u "\$\{[A-Z_]+:-[^}]*\}:\$\{[A-Z_]+:-[^}]*\}"''',
# Fixture password in the wizard handoff-card frontend test β€” a made-up literal the test
# asserts is RENDERED, so it can't be replaced with an env var. CI scans every pushed ref,
# so this must be allowlisted even while the branch that carries it is still in flight.
'''rX6d2A4sGBHFEcQT4TVQQJRQg7xtbDMg''',
]
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@ Pithead ships as **one product, one version** β€” the version lives in the top-l
[`VERSION`](VERSION) file and every released image is tagged with it. Releases are cut
per the process in [`docs/dev/releasing.md`](docs/dev/releasing.md).

## [1.15.0] - 2026-08-01

### Added

- **Running confirmed earnings (#787).** The dashboard's Confirmed on-chain block and the Telegram
`/earnings` reply now answer "what did I actually earn yesterday / this week / this month?" β€”
running yesterday / 7d / 30d totals from the confirmed payouts the view-only wallets record
(#381/#462), beside the existing estimate. Yesterday is the previous full calendar day in the
dashboard's timezone, not a trailing 24 hours, so it matches the daily summary's clock β€” even
across a DST change. A window that reaches back past the oldest recorded payout is marked
partial, with a footnote naming where the recorded history starts, so a total summed over less
than its labelled span never reads as a complete one. Both surfaces read one shared roll-up, so
they cannot drift apart.

### Dependencies

- Dashboard Python group (#788): `aiohttp` 3.14.3, `grpcio` 1.83.0 (floors still satisfy the
checked-in Tari gRPC stubs), plus test/dev tooling β€” `diff-cover` β‰₯ 10.4.1, `hypothesis`
β‰₯ 6.163.0, `ruff` 0.16.0, `pre-commit` β‰₯ 4.6.1.

## [1.14.1] - 2026-07-24

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.14.1
1.15.0
18 changes: 18 additions & 0 deletions build/dashboard/mining_dashboard/helper/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,24 @@ def format_xmr(amount):
return f"{val:.{dp}f} XMR"


def format_xtm(amount):
"""Format an XTM amount, the Tari sibling of :func:`format_xmr` β€” same magnitude-adaptive
precision, same "0 XTM" / em-dash edge cases.

Mirrors ``formatXtm`` in ``web/static/logic.mjs`` so a confirmed Tari total reads identically
in the bot and on the dashboard card (#387)."""
try:
val = float(amount)
except (ValueError, TypeError):
return "β€”"
if not math.isfinite(val):
return "β€”"
if val == 0:
return "0 XTM"
dp = 4 if val >= 1 else 6 if val >= 0.001 else 8
return f"{val:.{dp}f} XTM"


def format_duration(seconds):
"""
Formats a duration in seconds into a concise human-readable string.
Expand Down
90 changes: 90 additions & 0 deletions build/dashboard/mining_dashboard/service/earnings.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,15 @@
The XvB tier estimate is still deferred (per the Issue #12 discussion), and hashrate currently
donated to XvB isn't subtracted here β€” the estimate assumes the supplied hashrate mines via
P2Pool, which the dashboard states in its disclaimer.

Alongside the model sits ``confirmed_payouts_summary`` (#787): the same domain layer, but over
what the view-only wallets actually recorded (#381/#462) rather than what the model predicts.
It lives here so the dashboard card and the Telegram bot roll the running windows up exactly
once, from one implementation β€” the #61 principle again.
"""

import time

# Monero amounts are reported in atomic units (piconero); 1 XMR = 1e12 atomic.
ATOMIC_PER_XMR = 1_000_000_000_000
# Tari amounts are reported in microTari (Β΅T); 1 XTM = 1e6 Β΅T (#462).
Expand Down Expand Up @@ -75,3 +82,86 @@ def tari_seconds_to_block_per_hs(network_difficulty):
if network_difficulty <= 0:
return 0.0
return float(network_difficulty)


# Running-earnings windows (#787). The estimate above answers "what should this hashrate earn?";
# these answer "what did it actually earn?" from the confirmed on-chain payouts the view-only
# wallets record (#381/#462). Both the dashboard card and the Telegram bot read this one roll-up,
# so the two surfaces cannot drift apart (#61/#387).
RUNNING_WINDOWS = ("yesterday", "7d", "30d")


def previous_local_day(now):
"""``(start, end)`` unix seconds of the previous full **local** calendar day.

"Yesterday" means the calendar day, not a trailing 24 hours β€” that is the figure an operator
means by "what did I make yesterday". Local is the dashboard container's timezone
(``dashboard.timezone``), the same clock the daily summary fires on and the chart's payout
dates are stamped in, so all three agree on where a day ends.

Steps back through noon rather than subtracting 86 400 from today's midnight: a DST transition
makes a day 23 or 25 hours long, and the naive subtraction lands on the wrong date on exactly
those two days a year."""
lt = time.localtime(now)
today = time.mktime((lt.tm_year, lt.tm_mon, lt.tm_mday, 0, 0, 0, 0, 0, -1))
prev = time.localtime(today - 43_200)
start = time.mktime((prev.tm_year, prev.tm_mon, prev.tm_mday, 0, 0, 0, 0, 0, -1))
return start, today


def confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="xmr"):
"""Roll confirmed on-chain payouts into running totals + a count (#381/#462/#787).

``payouts`` is the stored-payout list (``storage.get_payouts(chain)``): each carries ``ts``
(unix seconds) and ``amount_atomic``. Sums are converted atomic→whole-unit at this edge only,
via ``divisor`` (piconero 1e12 for Monero, microTari 1e6 for Tari) with the amount keys prefixed
by ``unit`` (``xmr_*`` / ``xtm_*``). ``enabled`` is False when the feature is off
(``payouts is None``) β€” the UI then shows only the estimate; an empty list means "on, nothing
confirmed yet" (shows 0.000000).

Windows: ``24h``/``7d``/``30d`` are trailing spans from ``now``, ``yesterday`` is the previous
full local day (:func:`previous_local_day`), ``all`` is everything stored.

``partial`` marks each running window whose span begins before the oldest payout on record
(``since_ts``) β€” the sum then covers only part of the window it is labelled with, so the UI
says so rather than presenting it as a full one. It is deliberately a *may be incomplete*
signal, not a *is incomplete* one: a wallet that genuinely earned nothing for six weeks reads
as partial too, because the payouts table alone cannot tell "no payout arrived" apart from
"we weren't watching yet". Over-warning is the safe direction β€” the figure is never claimed to
be complete when it might not be."""
if payouts is None:
return {"enabled": False}
now = now if now is not None else time.time()
day, week, month = now - 86_400, now - 7 * 86_400, now - 30 * 86_400
y_start, y_end = previous_local_day(now)
atomic = dict.fromkeys(("24h", "yesterday", "7d", "30d", "all"), 0)
for p in payouts:
amt = p.get("amount_atomic", 0) or 0
ts = p.get("ts", 0) or 0
atomic["all"] += amt
if ts >= month:
atomic["30d"] += amt
if ts >= week:
atomic["7d"] += amt
if ts >= day:
atomic["24h"] += amt
# Half-open [start, end) so a payout landing exactly at midnight belongs to the day it
# starts, never to both days.
if y_start <= ts < y_end:
atomic["yesterday"] += amt
# Only positive stamps: a row with a missing/zero ts can't date the history, and letting it
# win the min would mark every window complete on the strength of a broken row.
stamps = [t for t in (p.get("ts", 0) or 0 for p in payouts) if t > 0]
since_ts = min(stamps, default=0)
starts = {"yesterday": y_start, "7d": week, "30d": month}
summary = {
"enabled": True,
"count": len(payouts),
"last_ts": max(stamps, default=0),
# Oldest payout on record β€” where the history these windows are drawn from begins. 0 when
# nothing is confirmed yet, which marks every running window partial.
"since_ts": since_ts,
"partial": {w: since_ts <= 0 or since_ts > starts[w] for w in RUNNING_WINDOWS},
}
summary.update({f"{unit}_{w}": v / divisor for w, v in atomic.items()})
return summary
90 changes: 84 additions & 6 deletions build/dashboard/mining_dashboard/service/telegram_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import requests

from mining_dashboard.config import config
from mining_dashboard.config.config import (
DASHBOARD_CONTROL_ENABLED,
HOST_IP,
Expand All @@ -24,9 +25,15 @@
format_duration,
format_hashrate,
format_xmr,
format_xtm,
)
from mining_dashboard.service import control_service
from mining_dashboard.service.earnings import xmr_per_hs_day, xtm_per_hs_day
from mining_dashboard.service.earnings import (
MICRO_PER_XTM,
confirmed_payouts_summary,
xmr_per_hs_day,
xtm_per_hs_day,
)
from mining_dashboard.service.egress import egress_posture_from_config
from mining_dashboard.service.metrics import build_metrics
from mining_dashboard.service.telegram_notifier import TELEGRAM_API_BASE
Expand Down Expand Up @@ -72,7 +79,7 @@
"/system β€” host disk, RAM, CPU, HugePages\n"
"/pool β€” P2Pool sidechain + Monero network\n"
"/xvb β€” XvB mode, tier, and raffle eligibility\n"
"/earnings β€” estimated P2Pool XMR per day\n"
"/earnings β€” estimated P2Pool XMR per day + confirmed yesterday/7d/30d\n"
"/luck β€” pool cadence: time-to-share, luck, PPLNS weight\n"
"/help β€” this message"
)
Expand Down Expand Up @@ -406,15 +413,56 @@ def format_xvb(metrics, host_label=""):
return "\n".join(lines)


def format_earnings(metrics, network, host_label=""):
def _running_lines(summary, unit_key, coin, fmt):
"""Confirmed running-earnings lines for '/earnings' (#787) β€” what the wallet actually received
over yesterday / 7d / 30d, against the estimate above them.

``summary`` is ``service.earnings.confirmed_payouts_summary``'s roll-up (the exact object the
dashboard's Confirmed on-chain block renders), so the bot re-derives nothing and the two
surfaces cannot disagree (#61/#387). Returns no lines when that chain's view-only wallet is off
β€” the estimate then stands alone, as it did before payout confirmation existed.

A window the server flagged partial gets a ``*`` and one footnote naming where the recorded
history starts, so a total summed over less than its labelled span never reads as a full one."""
if not summary or not summary.get("enabled"):
return []
partial = summary.get("partial") or {}
parts = [
f"{label} {fmt(summary.get(f'{unit_key}_{win}', 0) or 0)}"
+ ("*" if partial.get(win) else "")
for win, label in (("yesterday", "yesterday"), ("7d", "7d"), ("30d", "30d"))
]
lines = [f"\U0001f4e5 Confirmed {coin}: " + " Β· ".join(parts)]
if any(partial.get(w) for w in ("yesterday", "7d", "30d")):
since = summary.get("since_ts") or 0
where = (
f"starts {time.strftime('%Y-%m-%d', time.localtime(since))}"
if since
else "is empty β€” no payouts confirmed yet"
)
lines.append(f"* partial β€” recorded payout history {where}.")
return lines


def format_earnings(metrics, network, host_label="", confirmed=None, tari_confirmed=None):
"""Estimated P2Pool XMR earnings β€” the answer to '/earnings'. Reuses the same rates the
dashboard calculator uses (``xmr_per_hs_day``/``xtm_per_hs_day``) applied to the displayed
P2Pool 1h-average hashrate. The Tari line appears only while merge-mining figures are live β€”
the same hashrate earns the XTM alongside the XMR, in addition, not instead (#12, #117)."""
the same hashrate earns the XTM alongside the XMR, in addition, not instead (#12, #117).

``confirmed`` / ``tari_confirmed`` (#787) are the confirmed-payout roll-ups from the view-only
wallets (#381/#462), appended as running yesterday / 7d / 30d totals β€” the estimate is a model,
these are what arrived. ``None`` (that chain's wallet feature off) omits them entirely."""
reward_atomic = (network or {}).get("reward", 0) or 0
coeff_day = xmr_per_hs_day(reward_atomic, metrics.network_difficulty)
# Confirmed totals come off the wallet, not the network figures, so they survive the estimate
# being uncomputable β€” a stack waiting on network data can still say what it was paid.
running = _running_lines(confirmed, "xmr", "XMR", format_xmr) + _running_lines(
tari_confirmed, "xtm", "XTM", format_xtm
)
if coeff_day <= 0:
return f"{_prefix(host_label)}\U0001f4b0 Earnings estimate unavailable (waiting on network data)."
head = f"{_prefix(host_label)}\U0001f4b0 Earnings estimate unavailable (waiting on network data)."
return "\n".join([head, *running])
daily_1h = coeff_day * metrics.p2pool_1h
lines = [
f"{_prefix(host_label)}\U0001f4b0 Estimated P2Pool earnings",
Expand All @@ -437,6 +485,8 @@ def format_earnings(metrics, network, host_label=""):
if tari_daily > 0:
lines.append(f"Tari (merge-mined alongside): ~{tari_daily:.2f} XTM/day")
lines.append("Estimate only β€” excludes XvB-donated hashrate.")
# Estimate first, then what actually landed β€” the same order the dashboard card uses.
lines.extend(running)
return "\n".join(lines)


Expand Down Expand Up @@ -653,6 +703,28 @@ def __init__(
self.control_enabled = bool(self.enabled and control_enabled and self.allowed_ids)
self._gate = ControlGate(confirm_timeout)

def _payout_summary(self, chain):
"""Confirmed-payout roll-up for ``chain`` (#787), or ``None`` when that chain's view-only
wallet is off β€” the same ``None`` the dashboard passes to mean "feature off, show only the
estimate".

Reads the stored payouts and rolls them up through the shared
:func:`~mining_dashboard.service.earnings.confirmed_payouts_summary`, so the bot's running
totals are the identical numbers the dashboard card renders (#61/#387). The config flags are
read at call time (module attribute, not a from-import) so a flipped setting takes effect
without a re-import β€” matching ``build_state``'s handling of the same two flags."""
enabled = (
config.PAYOUT_CONFIRM_ENABLED
if chain == "monero"
else config.TARI_PAYOUT_CONFIRM_ENABLED
)
if not enabled:
return None
payouts = self.data_service.state_manager.get_payouts(chain)
if chain == "tari":
return confirmed_payouts_summary(payouts, divisor=MICRO_PER_XTM, unit="xtm")
return confirmed_payouts_summary(payouts)

def reply_for(self, text):
"""Map an incoming message to a reply string, or ``None`` to stay silent.

Expand Down Expand Up @@ -708,7 +780,13 @@ def reply_for(self, text):
if cmd == "xvb":
return format_xvb(metrics, self.host_label)
if cmd == "earnings":
return format_earnings(metrics, data.get("network", {}), self.host_label)
return format_earnings(
metrics,
data.get("network", {}),
self.host_label,
confirmed=self._payout_summary("monero"),
tari_confirmed=self._payout_summary("tari"),
)
if cmd == "luck":
return format_luck(metrics, self.host_label)
return None
Expand Down
24 changes: 22 additions & 2 deletions build/dashboard/mining_dashboard/web/static/components.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -595,23 +595,43 @@ function XvbTierBlock({ calc, hr, coeffDay, energy, est }) {
// holds their raw text.
// Confirmed on-chain payouts (#381), shown beside the estimate when the view-only wallet feature
// is on (`c.enabled`). Reads the unit-prefixed totals the server rolls up in
// _confirmed_payouts_summary (`xmr_*` for Monero, `xtm_*` for Tari); `fmt` is the matching coin
// confirmed_payouts_summary (`xmr_*` for Monero, `xtm_*` for Tari); `fmt` is the matching coin
// formatter and `unit` (XMR / XTM) picks the key prefix. Renders nothing when the feature is off,
// so the estimate stands alone.
// The running windows (#787) β€” yesterday, 7d, 30d β€” are what an operator checks against the
// estimate above; they carry a `*` and a footnote when the server flagged them partial, so a
// window summed over less history than its label claims never reads as a complete one. The date
// comes from `since_ts` (oldest payout on record) formatted in the VIEWER's locale, while the
// day boundaries were cut in the dashboard container's timezone β€” close enough to place the
// history, and the footnote says "starts", not a to-the-hour claim.
function confirmedBlock(c, fmt, unit) {
if (!c || !c.enabled) return null;
const k = unit.toLowerCase();
const n = c.count || 0;
const partial = c.partial || {};
const anyPartial = ["yesterday", "7d", "30d"].some((w) => partial[w]);
const since = c.since_ts ? new Date(c.since_ts * 1000).toLocaleDateString() : null;
const hint = since
? `Partial β€” payout history starts ${since}`
: "Partial β€” no payouts on record yet";
const running = (key, label) => html`
<${StatCard} label=${partial[key] ? `${label} *` : label}
value=${fmt(c[`${k}_${key}`])}
title=${partial[key] ? hint : ""} />`;
const note = `* ${hint.replace("Partial β€” ", "")} β€” the window covers only the history on record, not its full span.`;
return html`
<div class="confirmed-block">
<h4 class="confirmed-subhead">Confirmed on-chain</h4>
<div class="stat-grid">
${running("yesterday", "Yesterday")}
<${StatCard} label="Confirmed 24h" value=${fmt(c[`${k}_24h`])} />
<${StatCard} label="Confirmed 7d" value=${fmt(c[`${k}_7d`])} />
${running("7d", "Running 7d")}
${running("30d", "Running 30d")}
<${StatCard} label="Confirmed all-time" value=${fmt(c[`${k}_all`])} />
<${StatCard} label="Last payout" value=${formatAgo(c.last_ts)}
title=${"Across " + n + " confirmed payout" + (n === 1 ? "" : "s")} />
</div>
${anyPartial ? html`<p class="text-muted text-xs">${note}</p>` : null}
</div>`;
}

Expand Down
Loading