From 967805132b94ad753ea0023c8ac76e60b115369d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 28 Jul 2026 18:05:36 +0000 Subject: [PATCH] feat(earnings): running yesterday/7d/30d confirmed earnings on the dashboard and in Telegram (#787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earnings card and /earnings only ever answered "what should this hashrate earn?". The confirmed-payout side (#381/#462) already recorded what actually arrived, but only as 24h / 7d / all-time totals, and only on the dashboard. Adds the running windows an operator actually checks: yesterday, 7d, 30d, on both surfaces. - Move the payout roll-up out of web/views.py into service/earnings.py as confirmed_payouts_summary, so the dashboard card and the bot read one implementation instead of each rolling their own (#61/#387). - "Yesterday" is the previous full *calendar* day in the dashboard container's timezone — the same clock the daily summary fires on — not a trailing 24h. The day boundary steps back through noon so a 23/25-hour DST day still lands on yesterday's date. - Mark a running window partial when it reaches back further than the oldest payout on record, with a footnote naming where the history starts. It is a "may be incomplete" signal: stored payouts alone can't tell "nothing arrived" apart from "we weren't watching yet", so it over-warns rather than presenting a short window as a full one. - /earnings appends the confirmed totals per chain. They come off the wallet, not the network figures, so they still report when the estimate is waiting on network data. - Add format_xtm, mirroring formatXtm in logic.mjs, so a confirmed Tari total reads the same in the bot as on the card. Tier 1 throughout (the lowest tier that proves it): window math and the DST boundary in test_earnings.py, the bot's rendering and config gating in test_telegram_commands.py, the card's partial marking in components.test.mjs. Patch coverage 100%. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Y6dPxWC8EVdCQNgyyR3C9x --- .../mining_dashboard/helper/utils.py | 18 +++ .../mining_dashboard/service/earnings.py | 90 ++++++++++++ .../service/telegram_commands.py | 90 +++++++++++- .../web/static/components.mjs | 24 +++- build/dashboard/mining_dashboard/web/views.py | 46 +----- .../tests/frontend/components.test.mjs | 50 ++++++- build/dashboard/tests/helper/test_utils.py | 17 +++ .../dashboard/tests/service/test_earnings.py | 136 ++++++++++++++++++ .../tests/service/test_telegram_commands.py | 126 +++++++++++++++- build/dashboard/tests/web/test_views.py | 70 ++------- docs/dashboard.md | 24 +++- docs/dev/testing-strategy.md | 1 + docs/telegram.md | 2 +- 13 files changed, 581 insertions(+), 113 deletions(-) diff --git a/build/dashboard/mining_dashboard/helper/utils.py b/build/dashboard/mining_dashboard/helper/utils.py index bd0bc5d5..ab38244b 100644 --- a/build/dashboard/mining_dashboard/helper/utils.py +++ b/build/dashboard/mining_dashboard/helper/utils.py @@ -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. diff --git a/build/dashboard/mining_dashboard/service/earnings.py b/build/dashboard/mining_dashboard/service/earnings.py index 15f51576..19b01df6 100644 --- a/build/dashboard/mining_dashboard/service/earnings.py +++ b/build/dashboard/mining_dashboard/service/earnings.py @@ -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). @@ -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 diff --git a/build/dashboard/mining_dashboard/service/telegram_commands.py b/build/dashboard/mining_dashboard/service/telegram_commands.py index c16c2276..9ae29a1f 100644 --- a/build/dashboard/mining_dashboard/service/telegram_commands.py +++ b/build/dashboard/mining_dashboard/service/telegram_commands.py @@ -5,6 +5,7 @@ import requests +from mining_dashboard.config import config from mining_dashboard.config.config import ( DASHBOARD_CONTROL_ENABLED, HOST_IP, @@ -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 @@ -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" ) @@ -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", @@ -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) @@ -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. @@ -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 diff --git a/build/dashboard/mining_dashboard/web/static/components.mjs b/build/dashboard/mining_dashboard/web/static/components.mjs index dfd5b870..0bc02ebd 100644 --- a/build/dashboard/mining_dashboard/web/static/components.mjs +++ b/build/dashboard/mining_dashboard/web/static/components.mjs @@ -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`

Confirmed on-chain

+ ${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")} />
+ ${anyPartial ? html`

${note}

` : null}
`; } diff --git a/build/dashboard/mining_dashboard/web/views.py b/build/dashboard/mining_dashboard/web/views.py index 86edebea..4b8407a0 100644 --- a/build/dashboard/mining_dashboard/web/views.py +++ b/build/dashboard/mining_dashboard/web/views.py @@ -42,6 +42,7 @@ from mining_dashboard.service.earnings import ( ATOMIC_PER_XMR, MICRO_PER_XTM, + confirmed_payouts_summary, tari_seconds_to_block_per_hs, xmr_per_hs_day, xtm_per_hs_day, @@ -1464,39 +1465,6 @@ def build_badges(data, metrics, mode_variant, db_healthy=True, wallet_change=Non ) -def _confirmed_payouts_summary(payouts, now=None, divisor=ATOMIC_PER_XMR, unit="xmr"): - """Roll confirmed on-chain payouts into 24h / 7d / all-time totals + a count (#381/#462). - - ``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).""" - if payouts is None: - return {"enabled": False} - now = now if now is not None else time.time() - day, week = now - 86_400, now - 7 * 86_400 - atomic_24h = atomic_7d = atomic_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 >= week: - atomic_7d += amt - if ts >= day: - atomic_24h += amt - last_ts = max((p.get("ts", 0) or 0 for p in payouts), default=0) - return { - "enabled": True, - "count": len(payouts), - f"{unit}_24h": atomic_24h / divisor, - f"{unit}_7d": atomic_7d / divisor, - f"{unit}_all": atomic_all / divisor, - "last_ts": last_ts, - } - - def xvb_current_tier_reward_day(metrics, state_mgr): """XvB's published expected reward for the tier the fleet is CURRENTLY holding, as XMR/day (#712). @@ -1537,10 +1505,10 @@ def build_earnings(data, metrics, payouts=None, tari_payouts=None, xvb_day=None) """Expected-XMR-from-P2Pool calculator inputs for the Advanced view (Issue #12). ``payouts`` (#381), when the view-only wallet feature is on, is the stored confirmed-payout - list; it's rolled into a ``confirmed`` block (24h / 7d / all-time XMR) shown beside this - estimate — the estimate is a model, the confirmed figure is ground truth from the wallet. - ``tari_payouts`` (#462) is the same for the Tari side, rolled into ``tari_confirmed`` (XTM) - beside the Tari time-to-block estimate. + list; it's rolled into a ``confirmed`` block (yesterday / 24h / 7d / 30d / all-time XMR, #787) + shown beside this estimate — the estimate is a model, the confirmed figure is ground truth from + the wallet. ``tari_payouts`` (#462) is the same for the Tari side, rolled into + ``tari_confirmed`` (XTM) beside the Tari time-to-block estimate. This is a **P2Pool** mining calculator: it estimates the XMR earned by the hashrate that is actually mining on your P2Pool node — *not* the rig's total output. The what-if default is @@ -1593,10 +1561,10 @@ def build_earnings(data, metrics, payouts=None, tari_payouts=None, xvb_day=None) "disclaimer": _EARNINGS_DISCLAIMER, # Confirmed on-chain payouts (#381), beside the estimate above. {"enabled": False} when the # view-only wallet feature is off — the UI then shows only the estimate. - "confirmed": _confirmed_payouts_summary(payouts), + "confirmed": confirmed_payouts_summary(payouts), # Confirmed Tari payouts (#462), beside the Tari time-to-block estimate. XTM (microTari), # {"enabled": False} when the Tari view-only wallet feature is off. - "tari_confirmed": _confirmed_payouts_summary( + "tari_confirmed": confirmed_payouts_summary( tari_payouts, divisor=MICRO_PER_XTM, unit="xtm" ), } diff --git a/build/dashboard/tests/frontend/components.test.mjs b/build/dashboard/tests/frontend/components.test.mjs index 761edc2c..28742923 100644 --- a/build/dashboard/tests/frontend/components.test.mjs +++ b/build/dashboard/tests/frontend/components.test.mjs @@ -477,12 +477,18 @@ test('EarningsCard shows Confirmed on-chain under the estimates on both tabs whe s.earnings.tari_available = true; s.earnings.tari_coeff_day = 2e-3; s.earnings.confirmed = { - enabled: true, count: 3, xmr_24h: 0.25, xmr_7d: 0.75, xmr_all: 1.75, + enabled: true, count: 3, xmr_24h: 0.25, xmr_yesterday: 0.5, xmr_7d: 0.75, + xmr_30d: 1.25, xmr_all: 1.75, last_ts: Math.floor(Date.now() / 1000) - 3600, + since_ts: Math.floor(Date.now() / 1000) - 90 * 86400, + partial: { yesterday: false, '7d': false, '30d': false }, }; s.earnings.tari_confirmed = { - enabled: true, count: 1, xtm_24h: 0, xtm_7d: 4552.15, xtm_all: 4552.15, + enabled: true, count: 1, xtm_24h: 0, xtm_yesterday: 0, xtm_7d: 4552.15, + xtm_30d: 4552.15, xtm_all: 4552.15, last_ts: Math.floor(Date.now() / 1000) - 7200, + since_ts: Math.floor(Date.now() / 1000) - 90 * 86400, + partial: { yesterday: false, '7d': false, '30d': false }, }; let html = renderApp({ state: s }); // One confirmed block per tab, populated from the summary keys through the coin formatters. @@ -491,6 +497,14 @@ test('EarningsCard shows Confirmed on-chain under the estimates on both tabs whe assert.match(html, /1\.7500 XMR/); // xmr_all assert.match(html, /4552\.1500 XTM/); // xtm_all assert.match(html, /Last payout/); + // Running windows (#787): yesterday / 7d / 30d beside the existing 24h and all-time figures. + assert.match(html, /Yesterday/); + assert.match(html, /Running 7d/); + assert.match(html, /Running 30d/); + assert.match(html, /0\.500000 XMR/); // xmr_yesterday + assert.match(html, /1\.2500 XMR/); // xmr_30d + // History predates every window here, so nothing is marked partial and no footnote appears. + assert.doesNotMatch(html, /Partial/); // Estimates first, confirmed reality after — on the Tari tab the block follows the // Long-run Average table, mirroring the Monero tab's order. const tari = html.slice(html.indexOf('id="epanel-tari"'), html.indexOf('id="epanel-xvb"')); @@ -502,6 +516,38 @@ test('EarningsCard shows Confirmed on-chain under the estimates on both tabs whe assert.doesNotMatch(html, /Confirmed on-chain/); }); +test('EarningsCard marks running windows the payout history does not fully cover (#787)', () => { + const s = clone(); + s.earnings.available = true; + s.earnings.tari_confirmed = { enabled: false }; + // History starts 3 days ago: yesterday is covered, 7d and 30d reach behind it. + s.earnings.confirmed = { + enabled: true, count: 1, xmr_24h: 0, xmr_yesterday: 0.5, xmr_7d: 0.5, + xmr_30d: 0.5, xmr_all: 0.5, + last_ts: Math.floor(Date.now() / 1000) - 3 * 86400, + since_ts: Math.floor(Date.now() / 1000) - 3 * 86400, + partial: { yesterday: false, '7d': true, '30d': true }, + }; + let html = renderApp({ state: s }); + // Only the flagged windows carry the marker — a covered window must not be hedged. + assert.match(html, /Running 7d \*/); + assert.match(html, /Running 30d \*/); + assert.doesNotMatch(html, /Yesterday \*/); + // One footnote states where the recorded history starts, so a short window never reads as full. + assert.match(html, /payout history starts/); + assert.match(html, /covers only the history on record/); + // Nothing confirmed at all: every running window is flagged and the footnote says so instead + // of naming a date it doesn't have. + s.earnings.confirmed = { + enabled: true, count: 0, xmr_24h: 0, xmr_yesterday: 0, xmr_7d: 0, xmr_30d: 0, xmr_all: 0, + last_ts: 0, since_ts: 0, + partial: { yesterday: true, '7d': true, '30d': true }, + }; + html = renderApp({ state: s }); + assert.match(html, /Yesterday \*/); + assert.match(html, /no payouts on record yet/); +}); + test('EarningsCard XvB tab shows the published current-tier reward as a day/month/year table', () => { const s = clone(); s.earnings.available = true; diff --git a/build/dashboard/tests/helper/test_utils.py b/build/dashboard/tests/helper/test_utils.py index 8be0fbb8..2ec29ae1 100644 --- a/build/dashboard/tests/helper/test_utils.py +++ b/build/dashboard/tests/helper/test_utils.py @@ -11,6 +11,7 @@ format_hashrate, format_time_abs, format_xmr, + format_xtm, get_tier_info, is_ip_address, parse_hashrate, @@ -153,6 +154,22 @@ def test_zero_and_bad_data(self): assert format_xmr(float("inf")) == "—" +class TestFormatXtm: + """#387: the Tari sibling — mirrors formatXtm in web/static/logic.mjs, so a confirmed Tari + total (#787) reads identically in the bot and on the dashboard card.""" + + def test_precision_scales_with_magnitude(self): + assert format_xtm(4552.15) == "4552.1500 XTM" # >= 1 -> 4 dp + assert format_xtm(0.1234567) == "0.123457 XTM" # >= 0.001 -> 6 dp + assert format_xtm(0.00000123) == "0.00000123 XTM" # tiny -> 8 dp, not rounded to 0 + + def test_zero_and_bad_data(self): + assert format_xtm(0) == "0 XTM" + assert format_xtm(None) == "—" + assert format_xtm("invalid") == "—" + assert format_xtm(float("inf")) == "—" + + class TestFormatDuration: def test_branches(self): assert format_duration(90000) == "1d 1h 0m" diff --git a/build/dashboard/tests/service/test_earnings.py b/build/dashboard/tests/service/test_earnings.py index bdede8d3..a178cb2b 100644 --- a/build/dashboard/tests/service/test_earnings.py +++ b/build/dashboard/tests/service/test_earnings.py @@ -6,11 +6,16 @@ what-if hashrate; that scaling/formatting is tested in tests/frontend/logic.test.mjs. """ +import os +import time + import pytest from mining_dashboard.service.earnings import ( ATOMIC_PER_XMR, SECONDS_PER_DAY, + confirmed_payouts_summary, + previous_local_day, tari_seconds_to_block_per_hs, xmr_per_hs_day, xtm_per_hs_day, @@ -100,3 +105,134 @@ def test_is_difficulty_per_hs(self): def test_missing_or_bad_difficulty_is_zero(self, diff): # Zero difficulty -> zero rate -> the card shows "—" (graceful degradation). assert tari_seconds_to_block_per_hs(diff) == 0.0 + + +# --- Confirmed payouts summary (#381/#462, running windows #787) ----------------------- + + +def _local(year, month, day, hh=0, mm=0, ss=0): + """Unix seconds for a LOCAL wall-clock time. The summary cuts calendar days in the dashboard + container's timezone, so the fixtures are built the same way — the assertions then hold under + whatever TZ the suite happens to run in, instead of only under UTC.""" + return time.mktime((year, month, day, hh, mm, ss, 0, 0, -1)) + + +class TestPreviousLocalDay: + def test_is_yesterdays_midnight_to_todays(self): + start, end = previous_local_day(_local(2026, 7, 28, 15, 30)) + assert (start, end) == (_local(2026, 7, 27), _local(2026, 7, 28)) + + def test_is_stable_across_the_whole_day(self): + # Any hour of today must resolve to the same "yesterday" — including the two boundaries. + expected = (_local(2026, 7, 27), _local(2026, 7, 28)) + assert previous_local_day(_local(2026, 7, 28, 0, 0, 0)) == expected + assert previous_local_day(_local(2026, 7, 28, 23, 59, 59)) == expected + + def test_short_dst_day_still_lands_on_yesterday(self): + # Europe/London springs forward on 2026-03-29 (01:00 -> 02:00), making that day 23h long. + # Subtracting a flat 86_400 from 2026-03-30 midnight would land at 23:00 on the 28th — the + # WRONG date. Stepping back through noon keeps it on the 29th. + prev_tz = os.environ.get("TZ") + os.environ["TZ"] = "Europe/London" + time.tzset() + try: + start, end = previous_local_day(_local(2026, 3, 30, 12, 0)) + assert (start, end) == (_local(2026, 3, 29), _local(2026, 3, 30)) + assert end - start == 23 * 3_600 # the short day, proving it isn't a flat 24h subtract + finally: + if prev_tz is None: + os.environ.pop("TZ", None) + else: + os.environ["TZ"] = prev_tz + time.tzset() + + +class TestConfirmedPayoutsSummary: + # Local noon, so "yesterday" is unambiguously the previous calendar day. + NOW = _local(2026, 7, 28, 12, 0) + ONE_XMR = ATOMIC_PER_XMR + + def test_disabled_when_payouts_none(self): + # Feature off (storage returns None) -> only the enabled flag, no totals. + assert confirmed_payouts_summary(None) == {"enabled": False} + + def test_empty_list_is_on_with_zeros(self): + # On, nothing confirmed yet: enabled, count 0, every window 0.0, no last payout — and every + # running window flagged partial, because no history means no proof the windows are covered. + s = confirmed_payouts_summary([], now=self.NOW) + assert s == { + "enabled": True, + "count": 0, + "xmr_24h": 0.0, + "xmr_yesterday": 0.0, + "xmr_7d": 0.0, + "xmr_30d": 0.0, + "xmr_all": 0.0, + "last_ts": 0, + "since_ts": 0, + "partial": {"yesterday": True, "7d": True, "30d": True}, + } + + def test_trailing_windows_bucket_by_age(self): + # One payout in each band; 1 XMR = 1e12 piconero. 24h ⊆ 7d ⊆ 30d ⊆ all-time. + payouts = [ + {"ts": self.NOW - 3_600, "amount_atomic": self.ONE_XMR}, # 1h -> 24h, 7d, 30d, all + {"ts": self.NOW - 3 * 86_400, "amount_atomic": 2 * self.ONE_XMR}, # 3d -> 7d, 30d, all + {"ts": self.NOW - 10 * 86_400, "amount_atomic": 4 * self.ONE_XMR}, # 10d -> 30d, all + {"ts": self.NOW - 40 * 86_400, "amount_atomic": 8 * self.ONE_XMR}, # 40d -> all only + ] + s = confirmed_payouts_summary(payouts, now=self.NOW) + assert s["count"] == 4 + assert s["xmr_24h"] == 1.0 + assert s["xmr_7d"] == 3.0 + assert s["xmr_30d"] == 7.0 + assert s["xmr_all"] == 15.0 + assert s["last_ts"] == self.NOW - 3_600 + assert s["since_ts"] == self.NOW - 40 * 86_400 + + def test_yesterday_is_the_previous_calendar_day(self): + # A calendar day, NOT a trailing 24h: both edges of yesterday count, today's midnight and + # the day before's last second do not. Half-open [start, end) — midnight belongs to the day + # it opens, so a payout is never counted into two days. + payouts = [ + {"ts": _local(2026, 7, 26, 23, 59, 59), "amount_atomic": self.ONE_XMR}, # day before + {"ts": _local(2026, 7, 27, 0, 0, 0), "amount_atomic": 2 * self.ONE_XMR}, # yesterday + {"ts": _local(2026, 7, 27, 23, 59, 59), "amount_atomic": 4 * self.ONE_XMR}, # yesterday + {"ts": _local(2026, 7, 28, 0, 0, 0), "amount_atomic": 8 * self.ONE_XMR}, # today + ] + s = confirmed_payouts_summary(payouts, now=self.NOW) + assert s["xmr_yesterday"] == 6.0 + # The trailing 24h is a different span and deliberately disagrees: it reaches back into + # yesterday noon and includes today's midnight payout. + assert s["xmr_24h"] == 12.0 + + def test_partial_flags_track_recorded_history(self): + # History starts 10 days back: the 30d window reaches behind it (partial), 7d and yesterday + # sit inside it (complete). + payouts = [{"ts": self.NOW - 10 * 86_400, "amount_atomic": self.ONE_XMR}] + s = confirmed_payouts_summary(payouts, now=self.NOW) + assert s["partial"] == {"yesterday": False, "7d": False, "30d": True} + + def test_history_starting_today_marks_every_running_window(self): + # First payout landed this morning — nothing on record covers yesterday, 7d or 30d. + payouts = [{"ts": _local(2026, 7, 28, 9, 0), "amount_atomic": self.ONE_XMR}] + s = confirmed_payouts_summary(payouts, now=self.NOW) + assert s["partial"] == {"yesterday": True, "7d": True, "30d": True} + + def test_zero_timestamp_row_cannot_date_the_history(self): + # A row with no usable ts must not win the since_ts minimum and declare every window + # complete on the strength of a broken record. + payouts = [ + {"ts": 0, "amount_atomic": self.ONE_XMR}, + {"ts": self.NOW - 2 * 86_400, "amount_atomic": self.ONE_XMR}, + ] + s = confirmed_payouts_summary(payouts, now=self.NOW) + assert s["since_ts"] == self.NOW - 2 * 86_400 + assert s["partial"]["30d"] is True + + def test_tari_unit_and_divisor(self): + # Tari reuses the helper with the microTari divisor (1e6) and xtm_* keys. + payouts = [{"ts": self.NOW, "amount_atomic": 2_500_000}] + s = confirmed_payouts_summary(payouts, now=self.NOW, divisor=1_000_000, unit="xtm") + assert s["xtm_all"] == 2.5 + assert "xtm_30d" in s and "xtm_yesterday" in s and "xmr_all" not in s diff --git a/build/dashboard/tests/service/test_telegram_commands.py b/build/dashboard/tests/service/test_telegram_commands.py index 9c4c5122..5f3be419 100644 --- a/build/dashboard/tests/service/test_telegram_commands.py +++ b/build/dashboard/tests/service/test_telegram_commands.py @@ -6,12 +6,14 @@ """ import asyncio +import time from dataclasses import replace from types import SimpleNamespace import pytest from mining_dashboard.service import telegram_commands as tc +from mining_dashboard.service.earnings import confirmed_payouts_summary, previous_local_day from mining_dashboard.service.metrics import Metrics, SyncMetric _SYNCED = SyncMetric( @@ -382,6 +384,88 @@ def test_earnings_omits_tari_line_without_tari_figures(): assert "XMR/day" in out # the XMR estimate is unaffected +# --- Confirmed running earnings on /earnings (#787) --------------------------------------- +# +# The window math itself is proven once, in tests/service/test_earnings.py — these build the +# summaries through the real confirmed_payouts_summary so the bot is exercised against the exact +# object the dashboard card renders (#61/#387), and assert only what the bot does with it. + +_NET = {"reward": 600_000_000_000} +_ONE_XMR = 1_000_000_000_000 + + +def test_earnings_appends_confirmed_running_totals(): + # A year of history with a payout in each window → yesterday / 7d / 30d land as actuals under + # the estimate, all complete (history predates every window), so no marker and no footnote. + now = time.time() + day_start, _ = previous_local_day(now) + payouts = [ + {"ts": now - 365 * 86_400, "amount_atomic": _ONE_XMR}, # old — dates the history + {"ts": day_start + 3_600, "amount_atomic": 2 * _ONE_XMR}, # yesterday + {"ts": now - 3 * 86_400, "amount_atomic": 4 * _ONE_XMR}, # inside 7d + {"ts": now - 20 * 86_400, "amount_atomic": 8 * _ONE_XMR}, # inside 30d only + ] + out = tc.format_earnings( + _metrics(p2pool_1h=8000.0), _NET, confirmed=confirmed_payouts_summary(payouts, now=now) + ) + assert "Confirmed XMR: yesterday 2.0000 XMR · 7d 6.0000 XMR · 30d 14.0000 XMR" in out + assert "*" not in out.split("Confirmed XMR:")[1] # nothing partial → no marker, no footnote + assert out.index("1h avg") < out.index("Confirmed XMR:") # estimate leads, actuals follow + + +def test_earnings_marks_partial_windows_with_history_start(): + # History starts 3 days ago: yesterday is covered, 7d and 30d reach behind it and must say so + # rather than reading as full windows. + now = time.time() + payouts = [{"ts": now - 3 * 86_400, "amount_atomic": _ONE_XMR}] + out = tc.format_earnings( + _metrics(p2pool_1h=8000.0), _NET, confirmed=confirmed_payouts_summary(payouts, now=now) + ) + yesterday, seven, thirty = out.split("Confirmed XMR: ")[1].splitlines()[0].split(" · ") + assert not yesterday.endswith("*") and seven.endswith("*") and thirty.endswith("*") + assert "* partial — recorded payout history starts " in out + + +def test_earnings_partial_footnote_names_an_empty_history(): + # Wallet on, nothing confirmed yet: the zeros are honest, but every window is partial and the + # footnote says the history is empty rather than naming a date it doesn't have. + out = tc.format_earnings( + _metrics(p2pool_1h=8000.0), _NET, confirmed=confirmed_payouts_summary([], now=time.time()) + ) + assert "Confirmed XMR: yesterday 0 XMR* · 7d 0 XMR* · 30d 0 XMR*" in out + assert "* partial — recorded payout history is empty — no payouts confirmed yet." in out + + +def test_earnings_includes_confirmed_tari_totals(): + # #462 side: the same roll-up over microTari, rendered with the XTM precision the card uses. + now = time.time() + payouts = [{"ts": now - 2 * 86_400, "amount_atomic": 4_552_150_000}] # 4552.15 XTM + out = tc.format_earnings( + _metrics(p2pool_1h=8000.0), + _NET, + tari_confirmed=confirmed_payouts_summary(payouts, now=now, divisor=1_000_000, unit="xtm"), + ) + # History starts two days back, so yesterday is covered (no marker) while 7d/30d are not. + assert "Confirmed XTM: yesterday 0 XTM · 7d 4552.1500 XTM* · 30d 4552.1500 XTM*" in out + + +def test_earnings_confirmed_survives_missing_network_data(): + # The estimate needs live network figures; the confirmed totals come off the wallet and don't. + # A stack waiting on network data can still report what it was actually paid. + now = time.time() + payouts = [{"ts": now - 40 * 86_400, "amount_atomic": _ONE_XMR}] + out = tc.format_earnings(_metrics(), {}, confirmed=confirmed_payouts_summary(payouts, now=now)) + assert "unavailable" in out + assert "Confirmed XMR: yesterday 0 XMR · 7d 0 XMR · 30d 0 XMR" in out + + +def test_earnings_omits_confirmed_when_wallet_feature_is_off(): + # None (the default — no view-only wallet configured) → the estimate stands alone, exactly as + # it read before payout confirmation existed. + out = tc.format_earnings(_metrics(p2pool_1h=8000.0), _NET, confirmed=None, tari_confirmed=None) + assert "Confirmed" not in out + + def test_luck_reads_the_cadence_metrics(): # #84: the four figures come straight off Metrics — the same fields the dashboard card shows. out = tc.format_luck( @@ -540,7 +624,47 @@ def test_reply_for_pool_and_xvb(monkeypatch): def test_reply_for_earnings(monkeypatch): bot = _bot(monkeypatch, latest_data={"network": {"reward": 600_000_000_000}}, p2pool_1h=8000.0) - assert "XMR/day" in bot.reply_for("/earnings") + reply = bot.reply_for("/earnings") + assert "XMR/day" in reply + # Payout confirmation is off by default, so the estimate stands alone and storage is never read + # (the stub state_manager has no get_payouts — reaching for it would raise). + assert "Confirmed" not in reply + + +def test_reply_for_earnings_rolls_up_stored_payouts(monkeypatch): + # Feature on: /earnings reads the stored payouts per chain and appends the running totals. + # The flags are read off the config MODULE at call time, so flipping them here takes effect + # without a re-import — the same handling build_state uses. + monkeypatch.setattr(tc.config, "PAYOUT_CONFIRM_ENABLED", True) + monkeypatch.setattr(tc.config, "TARI_PAYOUT_CONFIRM_ENABLED", True) + now = time.time() + yday = previous_local_day(now)[0] + 3_600 + stored = { + "monero": [{"ts": yday, "amount_atomic": _ONE_XMR}], + "tari": [{"ts": yday, "amount_atomic": 2_000_000}], # 2 XTM + } + bot = _bot(monkeypatch, latest_data={"network": {"reward": 600_000_000_000}}, p2pool_1h=8000.0) + bot.data_service.state_manager.get_payouts = stored.get + reply = bot.reply_for("/earnings") + assert "Confirmed XMR: yesterday 1.0000 XMR" in reply # piconero divisor + assert "Confirmed XTM: yesterday 2.0000 XTM" in reply # microTari divisor + + +def test_reply_for_earnings_skips_the_chain_whose_wallet_is_off(monkeypatch): + # Monero confirmation on, Tari off → only the XMR totals appear, and Tari payouts are not read. + monkeypatch.setattr(tc.config, "PAYOUT_CONFIRM_ENABLED", True) + monkeypatch.setattr(tc.config, "TARI_PAYOUT_CONFIRM_ENABLED", False) + asked = [] + + def _payouts(chain): + asked.append(chain) + return [] + + bot = _bot(monkeypatch, latest_data={"network": {"reward": 600_000_000_000}}, p2pool_1h=8000.0) + bot.data_service.state_manager.get_payouts = _payouts + reply = bot.reply_for("/earnings") + assert asked == ["monero"] + assert "Confirmed XMR" in reply and "Confirmed XTM" not in reply def test_reply_for_hashrate_and_sync(monkeypatch): diff --git a/build/dashboard/tests/web/test_views.py b/build/dashboard/tests/web/test_views.py index 3a17bdd8..6a8cedf0 100644 --- a/build/dashboard/tests/web/test_views.py +++ b/build/dashboard/tests/web/test_views.py @@ -24,7 +24,6 @@ from mining_dashboard.web.views import ( _MAX_CHART_POINTS, _chart_tension, - _confirmed_payouts_summary, _mode_palette, _reject_flag, _rigforge_display, @@ -1241,61 +1240,6 @@ def test_thermal_hold_wins_over_temp_chip(self): assert not any("°C" in t for t in texts) # the hold chip replaces the temp chip -# --- Confirmed payouts summary (#381) ------------------------------------------------- - - -class TestConfirmedPayoutsSummary: - # A fixed "now" so the 24h/7d window boundaries are deterministic. - NOW = 1_000_000_000 - - def test_disabled_when_payouts_none(self): - # Feature off (storage returns None) -> only the enabled flag, no totals. - assert _confirmed_payouts_summary(None) == {"enabled": False} - - def test_empty_list_is_on_with_zeros(self): - # On, nothing confirmed yet: enabled, count 0, all windows 0.0, no last payout. - s = _confirmed_payouts_summary([], now=self.NOW) - assert s == { - "enabled": True, - "count": 0, - "xmr_24h": 0.0, - "xmr_7d": 0.0, - "xmr_all": 0.0, - "last_ts": 0, - } - - def test_windows_bucket_by_age(self): - # One payout in each band; 1 XMR = 1e12 piconero. 24h ⊆ 7d ⊆ all-time. - one_xmr = 1_000_000_000_000 - payouts = [ - {"ts": self.NOW - 3_600, "amount_atomic": one_xmr}, # 1h ago -> in 24h, 7d, all - {"ts": self.NOW - 3 * 86_400, "amount_atomic": 2 * one_xmr}, # 3d -> 7d, all - {"ts": self.NOW - 10 * 86_400, "amount_atomic": 4 * one_xmr}, # 10d -> all only - ] - s = _confirmed_payouts_summary(payouts, now=self.NOW) - assert s["count"] == 3 - assert s["xmr_24h"] == 1.0 - assert s["xmr_7d"] == 3.0 - assert s["xmr_all"] == 7.0 - assert s["last_ts"] == self.NOW - 3_600 - - def test_all_older_than_windows(self): - # Every payout predates both windows: 24h and 7d are 0, all-time still sums. - one_xmr = 1_000_000_000_000 - payouts = [{"ts": self.NOW - 30 * 86_400, "amount_atomic": 5 * one_xmr}] - s = _confirmed_payouts_summary(payouts, now=self.NOW) - assert s["xmr_24h"] == 0.0 - assert s["xmr_7d"] == 0.0 - assert s["xmr_all"] == 5.0 - - def test_tari_unit_and_divisor(self): - # Tari reuses the helper with the microTari divisor (1e6) and xtm_* keys. - payouts = [{"ts": self.NOW, "amount_atomic": 2_500_000}] - s = _confirmed_payouts_summary(payouts, now=self.NOW, divisor=1_000_000, unit="xtm") - assert s["xtm_all"] == 2.5 - assert "xtm_24h" in s and "xmr_all" not in s - - # --- Tari ----------------------------------------------------------------------------- @@ -1862,19 +1806,25 @@ def test_confirmed_disabled_by_default(self): e = build_earnings(self._NET, _metrics()) assert e["confirmed"] == {"enabled": False} - # ponytail: the 24h/7d/all windowing math is proven once, in TestConfirmedPayoutsSummary — - # this class only asserts build_earnings passes payouts through (enabled/empty/disabled). + # ponytail: the yesterday/24h/7d/30d/all windowing math is proven once, in + # tests/service/test_earnings.py::TestConfirmedPayoutsSummary — this class only asserts + # build_earnings passes payouts through (enabled/empty/disabled). def test_confirmed_enabled_but_empty(self): # Feature on, nothing confirmed yet → enabled with zeroed totals (shows 0.000000, not "—"). + # No history on record, so every running window is flagged partial (#787). e = build_earnings(self._NET, _metrics(), payouts=[]) assert e["confirmed"] == { "enabled": True, "count": 0, "xmr_24h": 0.0, + "xmr_yesterday": 0.0, "xmr_7d": 0.0, + "xmr_30d": 0.0, "xmr_all": 0.0, "last_ts": 0, + "since_ts": 0, + "partial": {"yesterday": True, "7d": True, "30d": True}, } def test_tari_confirmed_disabled_by_default(self): @@ -1888,9 +1838,13 @@ def test_tari_confirmed_enabled_but_empty(self): "enabled": True, "count": 0, "xtm_24h": 0.0, + "xtm_yesterday": 0.0, "xtm_7d": 0.0, + "xtm_30d": 0.0, "xtm_all": 0.0, "last_ts": 0, + "since_ts": 0, + "partial": {"yesterday": True, "7d": True, "30d": True}, } diff --git a/docs/dashboard.md b/docs/dashboard.md index f7674468..1755ebed 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -504,10 +504,26 @@ when you give the stack a way to check the chain. Set `monero.view_key` (the pri for your payout address) and the stack runs a **view-only** `monero-wallet-rpc` against your local node, scanning for confirmed incoming payouts. P2Pool pays each miner's share directly in a Monero block's coinbase, so the wallet is the only ground truth that a payout arrived. The Monero tab of -the earnings card then shows a **Confirmed on-chain** block under the estimate — 24-hour, 7-day, and -all-time XMR totals plus the time since the **last payout** — and a `payout_confirmed` alert fires -once per payout (Telegram and the other sinks). The Tari tab carries the same **Confirmed on-chain** -block in XTM once Tari payout confirmation is on (see the Tari note below). +the earnings card then shows a **Confirmed on-chain** block under the estimate — and a +`payout_confirmed` alert fires once per payout (Telegram and the other sinks). The Tari tab carries +the same **Confirmed on-chain** block in XTM once Tari payout confirmation is on (see the Tari note +below). + +| Figure | What it sums | +|---|---| +| **Yesterday** | The previous full calendar day, midnight to midnight in the dashboard's timezone (`dashboard.timezone`) — the same clock the daily summary fires on. Not a trailing 24 hours. | +| **Confirmed 24h** | The trailing 24 hours from now. Deliberately a different span from **Yesterday**, so the two disagree during the day. | +| **Running 7d** | The trailing 7 days from now. | +| **Running 30d** | The trailing 30 days from now. | +| **Confirmed all-time** | Every payout recorded, however far back. | +| **Last payout** | Time since the most recent confirmed payout, hover for the payout count. | + +A running window is marked with a `*` when it reaches back further than the oldest payout on +record — the total then covers only the history the wallet gave the dashboard, not the full span its +label names, and a footnote says where that history starts. Read the marker as *may be incomplete*: +a wallet that genuinely earned nothing for six weeks is marked too, because the recorded payouts +alone can't tell "no payout arrived" apart from "we weren't watching yet". A fresh install marks +every running window until history builds up behind it. Each confirmed Monero payout also drops a **Payouts** marker — a green coin at the block time it landed — onto the hashrate chart, on the same marker row as the event diamonds and raffle stars. diff --git a/docs/dev/testing-strategy.md b/docs/dev/testing-strategy.md index bce8c1cf..1423b99b 100644 --- a/docs/dev/testing-strategy.md +++ b/docs/dev/testing-strategy.md @@ -104,6 +104,7 @@ The deploy-time axes — each changes a real runtime path. Full table and assert | dashboard DB writes failing → `db_healthy:false` (#131) | data dir read-only | 1 ✅ (flag logic) · 4 ▶ (`--fault-injection`, #202) | | `/metrics` Prometheus exposition (#379), through Caddy + basic_auth | scrape | 1 ✅ (format) · 4 ▶ (`--check`) | | `share_stats` series populated on a mining box (#116) | polls land | 1 ✅ (shape) · 4 ▶ (`--check`) | +| Confirmed running earnings (#787): yesterday as a **calendar** day vs the trailing 24h/7d/30d spans, the DST-length day boundary, partial marking when a window outruns the recorded payout history, and one roll-up feeding both the dashboard card and `/earnings` | stored payouts | 1 ✅ (`test_earnings.py`, `test_telegram_commands.py`, `components.test.mjs`) | | Dashboard reads correct live state on a real stack | real daemons | 4 ▶ | ### G. CLI lifecycle (`pithead`) diff --git a/docs/telegram.md b/docs/telegram.md index d510aeaf..85a528ef 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -222,7 +222,7 @@ Run `./pithead apply` after editing. The commands: | `/system` | Host resources: disk, RAM, CPU + load, and HugePages. | | `/pool` | P2Pool sidechain type, pool hashrate, Monero network height + difficulty, PPLNS shares in window, current **effort** (luck indicator), **sidechain blocks found**, **share acceptance** (accepted/rejected + reject %), and the **best share** difficulty found. | | `/xvb` | XvB mode, current and target tier, the target tier's **threshold and cost** (holding a tier ≈ donating its threshold continuously, on both credited averages; a tier is **raffle status, not an XMR payout**), hashrate **routed** to XvB, the **credited** 1h/24h averages XvB measures (what sets your tier), raffle eligibility (PPLNS share), and a stale-data warning if the XvB feed is behind. | -| `/earnings` | Estimated P2Pool XMR per day/month, from both your **1h** and (once available) steadier **24h** average hashrate, plus the XTM the same hashrate **merge-mines alongside** (shown only while merge-mining figures are live). Excludes XvB-donated hashrate. | +| `/earnings` | Estimated P2Pool XMR per day/month, from both your **1h** and (once available) steadier **24h** average hashrate, plus the XTM the same hashrate **merge-mines alongside** (shown only while merge-mining figures are live). Excludes XvB-donated hashrate. With [payout confirmation](dashboard.md#payout-confirmation) on, the estimate is followed by what actually landed: **confirmed yesterday / 7d / 30d** totals per chain, the same figures and `*` partial marking the dashboard's Confirmed on-chain block shows. The confirmed totals come off the wallet, so they still report even when the estimate is waiting on network data. | | `/luck` | Pool cadence: time since the pool's last block (pool-wide, not your payout), estimated **time-to-share** for your hashrate, **luck** (actual vs. expected shares in the PPLNS window — over 100 % = running lucky), and **your PPLNS weight** (the sum of your share difficulty in the window). The same figures as the dashboard's Pool Cadence & Luck card. | | `/help` | The command list. |