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
63 changes: 47 additions & 16 deletions build/dashboard/mining_dashboard/web/static/components.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -516,9 +516,47 @@ class XvbComparison extends Component {
tiers.find((t) => t.name === calc.target_tier) ||
tiers[0];
const cmp = xvbTierComparison(sel, coeffDay);
// The actionable net: measured realization when the wallet has one, face value otherwise —
// the label always says which (#872: the face-value net once had the wrong SIGN).
const netShown = cmp.realizedNet !== null ? cmp.realizedNet : cmp.net;
// The actionable net, best first (#872): measured realization when the wallet has one; the
// measured-prior band otherwise; raw face value only when even the band can't be computed.
// Decided ONCE here — label, value, colour and tooltip below all follow `net`. The
// face-value net once had the wrong SIGN, so the label always says which figure this is.
// Range colour: red only when even the optimistic end loses, green only when even the
// pessimistic end profits — a zero-spanning band stays neutral.
const [lo, hi] = cmp.assumedNetRange || [null, null];
const net =
cmp.realizedNet !== null
? {
label: "Net / yr (measured)",
value: formatXmr(cmp.realizedNet),
cls: netCls(cmp.realizedNet),
title:
`XvB's published reward scaled to what this wallet's wins actually paid — ` +
`${calc.realization_pct}% of face value over the last ${calc.realization_wins} wins — ` +
`minus the P2Pool earnings given up.`,
}
: cmp.assumedNetRange
? {
label: "Net / yr (estimated)",
value: `${formatXmr(lo)} … ${formatXmr(hi)}`,
cls: hi < 0 ? "status-bad" : lo > 0 ? "status-ok" : "",
title:
"XvB's published reward scaled by the measured realization band from live " +
"deployments — wallets collected 24% of face value (tight margin over the " +
"tier threshold) to 42% (comfortable margin) — minus the P2Pool earnings " +
"given up. Your own measurement replaces this band once enough wins land.",
}
: {
label: "Net / yr (face value)",
value: cmp.net !== null ? formatXmr(cmp.net) : "—",
cls: cmp.net !== null ? netCls(cmp.net) : "",
title:
"XvB's published FACE-VALUE reward minus the P2Pool earnings given up — an " +
"upper bound: it prices every bonus hash at full block reward and assumes " +
"every won round runs to completion.",
};
// Fiat mirror wants one number; a range has none, so its fiat net shows "—".
const netShown =
cmp.realizedNet !== null ? cmp.realizedNet : cmp.assumedNetRange ? null : cmp.net;
// The same sustains rule the tier block states: donating the threshold must fit inside the
// donateable share of the what-if hashrate. An unsustainable tier's Net is "—" — showing,
// say, Mega's +56 XMR/yr to a 269 kH/s fleet would imply an unreachable payout.
Expand All @@ -538,20 +576,13 @@ class XvbComparison extends Component {
title="XvB's own published expected reward for this tier per year (their reward_calc figures, fetched over Tor). This is the raffle expectation across all qualifiers — donating above the tier threshold does NOT raise it." />
<${StatCard} label="Cost / yr" value=${cmp.cost !== null ? formatXmr(cmp.cost) : "—"}
title="P2Pool earnings foregone by donating the tier threshold for a year (threshold × the P2Pool daily rate × 365)." />
<${StatCard} label=${cmp.realizedNet !== null ? "Net / yr (measured)" : "Net / yr (face value)"}
value=${sustainable && netShown !== null ? formatXmr(netShown) : "—"}
cls=${sustainable && netShown !== null ? netCls(netShown) : ""}
<${StatCard} label=${net.label}
value=${sustainable ? net.value : "—"}
cls=${sustainable ? net.cls : ""}
title=${
!sustainable
? "Not shown — this tier isn't sustainable at your hashrate, so its payout isn't reachable."
: cmp.realizedNet !== null
? `XvB's published reward scaled to what this wallet's wins actually paid — ` +
`${calc.realization_pct}% of face value over the last ${calc.realization_wins} wins — ` +
`minus the P2Pool earnings given up.`
: "XvB's published FACE-VALUE reward minus the P2Pool earnings given up. The face " +
"value prices every bonus hash at full block reward and assumes every won round " +
"runs to completion — wallets collect less; this net is an upper bound. Once " +
"enough wins land, this figure switches to your measured payout realization."
sustainable
? net.title
: "Not shown — this tier isn't sustainable at your hashrate, so its payout isn't reachable."
} />
</div>
${
Expand Down
13 changes: 12 additions & 1 deletion build/dashboard/mining_dashboard/web/static/logic.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -323,9 +323,20 @@ export function xvbTierComparison(tier, coeffDay) {
tier && Number.isFinite(tier.expected_reward_year) ? tier.expected_reward_year : null;
const realized =
tier && Number.isFinite(tier.realized_reward_year) ? tier.realized_reward_year : null;
// The measured-prior band for unmeasured boxes (#872): published × [low, high] realization.
// Server-emitted only while no local measurement exists, so realizedNet and assumedNetRange
// are mutually exclusive by construction.
const assumed =
tier &&
Array.isArray(tier.assumed_reward_year_range) &&
tier.assumed_reward_year_range.every(Number.isFinite)
? tier.assumed_reward_year_range
: null;
const net = expected !== null && cost !== null ? expected - cost : null;
const realizedNet = realized !== null && cost !== null ? realized - cost : null;
return { expected, cost, net, realized, realizedNet };
const assumedNetRange =
assumed !== null && cost !== null ? [assumed[0] - cost, assumed[1] - cost] : null;
return { expected, cost, net, realized, realizedNet, assumedNetRange };
}

// Decimal places for a coin amount: more for small amounts (a day's earnings can be a tiny
Expand Down
49 changes: 41 additions & 8 deletions build/dashboard/mining_dashboard/web/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1510,16 +1510,28 @@ def xvb_current_tier_key(metrics, tiers):


# Measuring what a raffle win actually pays (#866/#872). A win's bonus round mines for up to an
# hour and lands as ordinary small P2Pool payouts shortly after; production measurement put the
# attributable payout mass inside 2h of the win timestamp. A win younger than the settle window
# may still have payouts in flight, so it is left out of the sample rather than dragging the
# factor down. Below the minimum sample the factor is noise — callers fall back to the published
# figure and the UI labels it face value.
_XVB_WIN_PAYOUT_WINDOW_S = 2 * 3600
# hour and its shares then ride the PPLNS window — a baseline-subtracted stream measurement on a
# production wallet (17 wins, two eras) put ~90% of the attributable excess inside 6h of the win
# timestamp, front-loaded in the first two hours. A win younger than the settle window may still
# have payouts in flight, so it is left out of the sample rather than dragging the factor down.
# Below the minimum sample the factor is noise — callers fall back to the published figure and
# the UI labels it face value.
_XVB_WIN_PAYOUT_WINDOW_S = 6 * 3600
_XVB_WIN_SETTLE_S = 12 * 3600
_XVB_REALIZATION_MIN_WINS = 5
_XVB_REALIZATION_WINDOW_S = 45 * SECONDS_PER_DAY

# Measured realization PRIOR for boxes with no local measurement (#872): the band a wallet's
# collected-vs-published fraction actually landed in on live deployments (Jun–Aug 2026, one
# wallet, two regimes; baseline-subtracted 6h post-win payout streams). 0.24 = Whale with the
# credited average riding the 100k round minimum (terminated rounds); 0.42 = VIP with a
# comfortable margin above its threshold. The mechanism behind the sub-1.0 ceiling even at
# comfortable margin is not visible from outside XvB, so this is an empirical bound, not a
# model — a local measurement (xvb_realization) supersedes it.
# ponytail: two hard-coded endpoints from one wallet's history — recalibrate if more deployments
# report measurements outside the band.
XVB_REALIZATION_PRIOR = (0.24, 0.42)


def xvb_forecast_tier_key(metrics, tiers):
"""The tier the expected-wins forecast should speak to: held, else targeted (#866).
Expand Down Expand Up @@ -1558,7 +1570,7 @@ def xvb_expected_wins_day(round_stats_state, tier_key, tiers):
return total if total > 0 else None


def xvb_realization(payouts, raffle_wins, xvb_day, expected_wins_day, now=None):
def xvb_realization(payouts, raffle_wins, xvb_day, expected_wins_day, now=None, p2pool_day=None):
"""Measured fraction of XvB's published expectation this wallet actually collects (#866/#872).

Numerator: mean confirmed XMR landing within the attribution window after each settled win in
Expand Down Expand Up @@ -1590,6 +1602,11 @@ def xvb_realization(payouts, raffle_wins, xvb_day, expected_wins_day, now=None):
)
/ ATOMIC_PER_XMR
)
# Ordinary P2Pool payouts also land inside the attribution windows; left in, they inflate the
# factor (the failure mode this whole measurement exists to prevent). Subtract the expected
# baseline: the box's own linear P2Pool rate over the windowed hours.
if p2pool_day and p2pool_day > 0:
realized -= p2pool_day * (_XVB_WIN_PAYOUT_WINDOW_S / SECONDS_PER_DAY) * len(stamps)
frac = (realized / len(stamps)) / face_per_win
return (max(0.0, min(1.0, frac)), len(stamps))

Expand Down Expand Up @@ -1837,6 +1854,18 @@ def _odds_day(key):
if estimates_available and key in estimates and realization
else None
),
# Unmeasured boxes still get a calculable band (#872): the published figure
# scaled by the measured realization PRIOR below. None once a local
# measurement exists (realized_reward_year supersedes it) or estimates are
# stale — the two never show together.
"assumed_reward_year_range": (
[
float(estimates[key]) * XVB_REALIZATION_PRIOR[0],
float(estimates[key]) * XVB_REALIZATION_PRIOR[1],
]
if estimates_available and key in estimates and not realization
else None
),
"win_odds_day": _odds_day(key),
"players_avg": (round_types.get(key) or {}).get("players_avg"),
}
Expand Down Expand Up @@ -2003,7 +2032,11 @@ def build_state(data, state_mgr, range_arg, window=None, avg_window=DEFAULT_HASH
state_mgr.get_xvb_round_stats(), xvb_forecast_tier_key(metrics, xvb_tiers), xvb_tiers
)
xvb_realized = xvb_realization(
monero_payouts, raffle_wins, earnings["xvb_day"], xvb_wins_day
monero_payouts,
raffle_wins,
earnings["xvb_day"],
xvb_wins_day,
p2pool_day=earnings["coeff_day"] * metrics.p2pool_30d,
)

egress = egress_posture_from_config() # per-component egress route + privacy roll-up (#170)
Expand Down
25 changes: 25 additions & 0 deletions build/dashboard/tests/frontend/components.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,31 @@ test('XvB tier comparison prefers the measured net and shows the draw behind it
assert.doesNotMatch(fv, /id="xvb-draw-line"/);
});

test('XvB tier comparison shows the estimated band on an unmeasured box (#872)', () => {
const base = clone();
base.earnings.available = true;
base.earnings.coeff_day = 1e-7;
base.earnings.p2pool_hr = 200000;
base.xvb_calc = {
enabled: true, estimates_available: true, estimates_stale: false, max_fraction: 0.85,
current_tier: 'Whale (100.00 kH/s+)', target_tier: 'Whale (100.00 kH/s+)',
target_threshold: 100000, sustainable: true, note: 'raffle status', mode_note: null,
realization_pct: null, realization_wins: null,
tiers: [
{ name: 'Whale (100.00 kH/s+)', threshold: 100000, expected_reward_year: 6.17,
realized_reward_year: null, assumed_reward_year_range: [6.17 * 0.19, 6.17 * 0.55],
win_odds_day: 0.84, players_avg: 8.2 },
],
};
const out = renderApp({ state: base });
// Both endpoints of published × [0.19, 0.55] − 3.65 cost render, labeled estimated; the
// face-value net (+2.52) must not appear as the acted-on figure.
assert.match(out, /Net \/ yr \(estimated\)/);
assert.match(out, /-2\.477\d* XMR … -0\.256\d* XMR/);
assert.doesNotMatch(out, /2\.5200 XMR/);
assert.match(out, /24% of face value \(tight margin/);
});

test('CadenceCard shows the — placeholders on a cold stack, real figures when available (#84)', () => {
// The base fixture has no pool difficulty → cadence.available === false → server-sent dashes.
const cold = renderApp();
Expand Down
14 changes: 14 additions & 0 deletions build/dashboard/tests/frontend/logic.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,20 @@ test('xvbTierComparison: measured realization yields realizedNet — the sign th
assert.ok(c.realizedNet < 0);
});

test('xvbTierComparison: the prior band yields assumedNetRange, exclusive with measured (#872)', () => {
const tier = {
name: 'Whale', threshold: 100_000,
expected_reward_year: 6.17, realized_reward_year: null,
assumed_reward_year_range: [6.17 * 0.19, 6.17 * 0.55],
};
const c = xvbTierComparison(tier, 1e-7); // cost 3.65
assert.ok(Math.abs(c.assumedNetRange[0] - (6.17 * 0.19 - 3.65)) < 1e-9);
assert.ok(Math.abs(c.assumedNetRange[1] - (6.17 * 0.55 - 3.65)) < 1e-9);
// No cost (network stats down) -> no range either; absent field -> null (old server).
assert.equal(xvbTierComparison(tier, 0).assumedNetRange, null);
assert.equal(xvbTierComparison({ name: 'W', threshold: 1, expected_reward_year: 1 }, 1e-7).assumedNetRange, null);
});

test('xvbTierComparison: unmeasured realization stays null — never fabricated (#872)', () => {
const tier = { name: 'Whale', threshold: 100_000, expected_reward_year: 6.17, realized_reward_year: null };
const c = xvbTierComparison(tier, 1e-7);
Expand Down
24 changes: 24 additions & 0 deletions build/dashboard/tests/web/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -1971,6 +1971,16 @@ def test_missing_inputs_yield_none(self):
# A hostile/corrupt negative published figure gives a negative face value — no factor.
assert xvb_realization(self._payouts(1), self.WINS, -0.016, 1.0, now=self.NOW) is None

def test_baseline_subtraction_removes_ordinary_p2pool_leak(self):
# Ordinary P2Pool payouts land inside win windows too; the box's linear rate over the
# windowed hours is subtracted so the factor measures only the wins' excess. Here the
# 4.8 mXMR gross per win contains 1.6 mXMR of baseline (6.4 mXMR/day × 6h): excess
# 3.2 mXMR against a 16 mXMR face => 0.2, not the inflated 0.3.
out = xvb_realization(
self._payouts(4_800_000_000), self.WINS, 0.016, 1.0, now=self.NOW, p2pool_day=0.0064
)
assert out == (pytest.approx(0.2), 6)


class TestEarningsVsActualTempering:
NOW = 1_760_000_000
Expand Down Expand Up @@ -2307,6 +2317,20 @@ def test_realization_scales_published_rewards_into_realized(self):
assert out["realization_pct"] == 19
assert out["realization_wins"] == 15

def test_unmeasured_boxes_get_the_prior_band_measured_boxes_do_not(self):
# #872: no local measurement -> published × the measured prior band, so "should I enable
# this" is answerable everywhere. A measured factor supersedes it (never both), and stale
# estimates null both — a band cannot resurrect a stale face value.
out = build_xvb_calc(_metrics(), self._sm())
whale = next(t for t in out["tiers"] if t["threshold"] == 100_000)
lo, hi = views.XVB_REALIZATION_PRIOR
assert whale["assumed_reward_year_range"] == pytest.approx([6.17 * lo, 6.17 * hi])
out = build_xvb_calc(_metrics(), self._sm(), realization=(0.19, 15))
assert all(t["assumed_reward_year_range"] is None for t in out["tiers"])
stale = self._sm(last_update=time.time() - XVB_STATS_STALE_AFTER_S - 1)
out = build_xvb_calc(_metrics(), stale)
assert all(t["assumed_reward_year_range"] is None for t in out["tiers"])

def test_no_realization_leaves_realized_none(self):
# Unmeasured (too few wins / payout confirmation off): realized stays None so the client
# falls back to face value AND says so — never a fabricated factor.
Expand Down
2 changes: 1 addition & 1 deletion docs/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -644,7 +644,7 @@ Below the tier figures, a **per-tier payout comparison** dropdown weighs each do
|---|---|
| **Expected (XvB)** | XvB's own published expected reward for the tier, in XMR per year. This is XvB's pre-computed `reward_calc` figure for the tier's donor round, fetched over Tor from `reward_estimate_pub.txt` — the dashboard does not re-derive it. It is the raffle expectation across all qualifiers, so donating **above** the tier threshold does not raise it. It is also **face value**: it prices every bonus hash at full block reward and assumes every won round runs to completion — wallets collect less. `estimate unavailable` when the fetch is stale or failed — never a stale figure implied fresh. |
| **Cost / yr** | The P2Pool earnings given up by donating the tier threshold for a year: `threshold × the P2Pool daily rate × 365`, using the same rate the Monero tab shows. |
| **Net / yr** | The reward minus the P2Pool earnings given up — the number to act on. Labeled **(measured)** when enough of your wins have confirmed payouts to measure what they actually paid: the published reward is scaled to that measured fraction first, because the face-value net can carry the wrong **sign** (a production Whale box showed +2.97 XMR/yr while the measured net was about −1.8). Labeled **(face value)** until then — read it as an upper bound. |
| **Net / yr** | The reward minus the P2Pool earnings given up — the number to act on. Labeled **(measured)** when enough of your wins have confirmed payouts to measure what they actually paid: the published reward is scaled to that measured fraction first, because the face-value net can carry the wrong **sign** (a production Whale box showed +2.97 XMR/yr while the measured net was about −1.8). Before your own measurement exists it is labeled **(estimated)** and shows a **range**: the published reward scaled by the realization band measured on live deployments — wallets collected 24% of face value (donation riding the tier threshold, terminated rounds) to 42% (comfortable margin) — so "is this worth enabling" is answerable on a fresh box. Red only when even the optimistic end loses; green only when even the pessimistic end profits. Falls back to a labeled **(face value)** upper bound only when no band can be computed. |

Below the figures, a **draw line** shows the selected tier's raffle odds from the winners file:
about how many wins per 30 days its rounds pay out and among how many qualifiers the draw runs. A
Expand Down