diff --git a/build/dashboard/mining_dashboard/web/static/components.mjs b/build/dashboard/mining_dashboard/web/static/components.mjs
index 25275402..da6af50d 100644
--- a/build/dashboard/mining_dashboard/web/static/components.mjs
+++ b/build/dashboard/mining_dashboard/web/static/components.mjs
@@ -32,7 +32,7 @@ import {
THEME_ORDER,
uptimeCell,
WORKER_COLUMNS,
- xvbTierComparison,
+ xvbDecisionRows,
} from "./logic.mjs";
import { MineCartTrain } from "./minecart.mjs";
import { Component, Fragment, html } from "./preact.mjs";
@@ -492,134 +492,79 @@ function NetworkCard({ state }) {
`;
}
-// XvB per-tier payout comparison dropdown (#118). Picks one of the four donor tiers and weighs XvB's
-// OWN published expected reward for it (server-fetched over Tor) against the P2Pool earnings donating
-// that tier costs, and the net. Defaults to the operator's target tier. When XvB's estimate is
-// stale/unavailable it shows the tier cost with an "estimate unavailable" note — never a fabricated
-// number. Selection is local UI state; the whole block is a raffle comparison, not a claim that
-// donating above a tier threshold pays more (it does not — the draw is random among qualifiers).
-class XvbComparison extends Component {
- constructor(props) {
- super(props);
- this.state = { selected: null };
- this.onSelect = (e) => this.setState({ selected: e.target.value });
- }
-
- render() {
- const { calc, coeffDay, hr, energy } = this.props;
- const tiers = (calc && calc.tiers) || [];
- if (!tiers.length) return null;
- const { selected } = this.state;
- // Default the dropdown to the operator's configured target tier; fall back to the lowest.
- const sel =
- tiers.find((t) => t.name === selected) ||
- tiers.find((t) => t.name === calc.target_tier) ||
- tiers[0];
- const cmp = xvbTierComparison(sel, coeffDay);
- // 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.
- const sustainable = hr > 0 && sel.threshold <= hr * (calc.max_fraction || 0);
- const expected =
- calc.estimates_available && cmp.expected !== null
- ? formatXmr(cmp.expected)
- : "estimate unavailable";
- return html`
+// XvB tier decision table (#872, study-final): the analytical tool a miner decides with. Every
+// donor tier on one row — draw odds (live winners feed), cost at YOUR hashrate, XvB's published
+// face value AND the study estimate (face x the measured on-chain delivery band) side by side,
+// and a coloured net verdict. This wallet's own measured wins supersede the study band when they
+// exist. Unsustainable tiers stay visible but grayed with the net withheld. No dropdown: the
+// comparison IS the decision, so all tiers show at once.
+function XvbDecisionTable({ calc, coeffDay, hr, energy }) {
+ const rows = xvbDecisionRows(calc, coeffDay, hr);
+ if (!rows.length) return null;
+ const measured = calc.realization_pct !== null && calc.realization_pct !== undefined;
+ const fmtRange = (r) =>
+ r === null ? "—" : r[0] === r[1] ? formatXmr(r[0]) : `${formatXmr(r[0])} … ${formatXmr(r[1])}`;
+ const estHeader = measured
+ ? `Yours (${calc.realization_pct}% × ${calc.realization_wins} wins)`
+ : "Study est. / yr";
+ // Fiat mirror (#520) for the best sustainable net only — one line, never a fiat number whose
+ // XMR figure is hidden.
+ const best = rows.filter((r) => r.sustainable && r.net).sort((a, b) => b.net[1] - a.net[1])[0];
+ return html`
-
-
-
- <${StatCard} label="Expected (XvB)" value=${expected} cls="c-purple"
- 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=${net.label}
- value=${sustainable ? net.value : "—"}
- cls=${sustainable ? net.cls : ""}
- title=${
- sustainable
- ? net.title
- : "Not shown — this tier isn't sustainable at your hashrate, so its payout isn't reachable."
-} />
+
+
+ XvB's figures are face value. A 25-round single-wallet on-chain audit (Jun–Aug
+ 2026, all three sidechains) measured winners receiving 33% of face (95% CI
+ 28–39%), with at most a small margin effect; a 14-winner public crawl
+ corroborates (no winner near face value). The
+ ${measured ? "Yours" : "Study"} column prices that in; the Net verdict uses it.
+
${
- // The draw behind the XMR figure (#872): how often this round type comes up and
- // against how many qualifiers — which also makes a single-qualifier tier (whose
- // headline reward evaporates the moment a second donor qualifies) self-evident.
- sel.win_odds_day > 0
- ? html`
`
- : null
- }
- ${
- // Fiat mirror of the XMR/yr figures (#520): same visibility guards as the cards
- // above (never a fiat number whose XMR figure is hidden), at the XMR price in use.
- energy && energy.xmr_price > 0
+ energy && energy.xmr_price > 0 && best
? html`
Not sustainable at your hashrate — holding this tier needs about ${fmtHashrate(sel.threshold)} donated continuously, more than your hashrate can spare.
`
+ ${best.name}: net ≈ ${formatFiat(coinFiat(best.net[0], energy.xmr_price), energy.currency)}
+ … ${formatFiat(coinFiat(best.net[1], energy.xmr_price), energy.currency)} per year at the current XMR price`
: null
}
${
calc.estimates_available
- ? "From XvB's published per-tier estimate, fetched over Tor."
- : "Expected reward estimate unavailable — showing tier cost only."
+ ? "XvB figures fetched over Tor from the operator's published estimates; odds from the public winners feed; the draw is random among qualifiers — donating above a threshold buys no extra odds."
+ : "Expected reward estimate unavailable — tier costs only."
}
`;
- }
}
// XvB tier / raffle block (#118), inside the earnings card and driven by the same what-if
@@ -658,7 +603,7 @@ function XvbTierBlock({ calc, hr, coeffDay, energy, est }) {
price=${energy ? energy.xmr_price : 0} currency=${energy ? energy.currency : "USD"} />`
: null
}
- <${XvbComparison} calc=${calc} coeffDay=${coeffDay} hr=${hr} energy=${energy} />
+ <${XvbDecisionTable} calc=${calc} coeffDay=${coeffDay} hr=${hr} energy=${energy} />
`;
}
diff --git a/build/dashboard/mining_dashboard/web/static/logic.mjs b/build/dashboard/mining_dashboard/web/static/logic.mjs
index 51654ff8..f249f784 100644
--- a/build/dashboard/mining_dashboard/web/static/logic.mjs
+++ b/build/dashboard/mining_dashboard/web/static/logic.mjs
@@ -306,37 +306,61 @@ export function computeXvbTier(hashrateHs, calc) {
return best && { tier: best.name, threshold: best.threshold, cost: best.threshold };
}
-// XvB per-tier payout comparison (#118, made honest by #872). Weighs XvB's expected reward for a
-// tier against what donating that tier costs in foregone P2Pool earnings: cost = threshold H/s ×
-// the daily P2Pool rate (`coeffDay`, same `earnings.coeff_day` the card already uses) × 365.
-// TWO reward figures: `expected` is XvB's published face value (prices every bonus hash at full
-// block reward — measured wallets collect a fraction), `realized` is that figure × the wallet's
-// own measured win realization (server-computed, null until enough wins measure it). The
-// actionable net prefers realized — the published face value once flipped the net's SIGN on a
-// production box (+2.97 shown, −1.8 measured, #872). Either figure null (unavailable/stale/
-// unmeasured) => its net is null: we never fabricate the reward. This is a raffle-tier
-// comparison, NOT a claim that donating more within a tier helps.
-export function xvbTierComparison(tier, coeffDay) {
- const cost =
- tier && tier.threshold > 0 && coeffDay > 0 ? tier.threshold * coeffDay * DAYS_PER_YEAR : null;
- const expected =
- 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;
- const assumedNetRange =
- assumed !== null && cost !== null ? [assumed[0] - cost, assumed[1] - cost] : null;
- return { expected, cost, net, realized, realizedNet, assumedNetRange };
+// XvB tier decision rows (#872, study-final). ONE row per donor tier with everything a miner
+// needs to choose a direction, all from measured or operator-published data:
+// odds — how often this tier's rounds pay out and among how many qualifiers (live feed)
+// cost — P2Pool earnings forgone donating the threshold (threshold x coeffDay x 365)
+// xvbSays — XvB's published face-value reward (their number, shown as theirs)
+// study — [lo, hi]: xvbSays x the measured delivery band (server-emitted; on-chain study
+// constant, 25 rounds: 32% of face, CI 27-38%, margin-invariant)
+// yours — xvbSays x THIS wallet's measured realization, when >=5 wins exist (supersedes)
+// net — the actionable verdict: (yours ?? study ?? face) minus cost; [lo,hi] when a band
+// A tier the what-if hashrate cannot sustain is flagged, its net withheld (an unreachable payout
+// must not render as reachable). Pure + unit-tested; the component only renders these rows.
+export function xvbDecisionRows(calc, coeffDay, hr) {
+ if (!calc || !calc.enabled) return [];
+ const rows = [];
+ for (const t of calc.tiers || []) {
+ const cost = t.threshold > 0 && coeffDay > 0 ? t.threshold * coeffDay * DAYS_PER_YEAR : null;
+ const xvbSays = Number.isFinite(t.expected_reward_year) ? t.expected_reward_year : null;
+ const yours = Number.isFinite(t.realized_reward_year) ? t.realized_reward_year : null;
+ const study =
+ Array.isArray(t.assumed_reward_year_range) &&
+ t.assumed_reward_year_range.every(Number.isFinite)
+ ? t.assumed_reward_year_range
+ : null;
+ const sustainable = hr > 0 && t.threshold <= hr * (calc.max_fraction || 0);
+ let mode = "none";
+ let net = null;
+ if (cost !== null && yours !== null) {
+ mode = "yours";
+ net = [yours - cost, yours - cost];
+ } else if (cost !== null && study !== null) {
+ mode = "study";
+ net = [study[0] - cost, study[1] - cost];
+ } else if (cost !== null && xvbSays !== null) {
+ mode = "face";
+ net = [xvbSays - cost, xvbSays - cost];
+ }
+ // Verdict colour: red only when even the optimistic end loses; green only when even the
+ // pessimistic end profits; a zero-spanning band stays neutral.
+ const cls = net === null ? "" : net[1] < 0 ? "status-bad" : net[0] > 0 ? "status-ok" : "";
+ rows.push({
+ name: t.name,
+ threshold: t.threshold,
+ sustainable,
+ oddsPer30d: t.win_odds_day > 0 ? t.win_odds_day * 30 : null,
+ players: t.players_avg > 0 ? t.players_avg : null,
+ cost,
+ xvbSays,
+ study,
+ yours,
+ net,
+ mode,
+ cls,
+ });
+ }
+ return rows;
}
// Decimal places for a coin amount: more for small amounts (a day's earnings can be a tiny
diff --git a/build/dashboard/mining_dashboard/web/views.py b/build/dashboard/mining_dashboard/web/views.py
index e360d777..9e0379f4 100644
--- a/build/dashboard/mining_dashboard/web/views.py
+++ b/build/dashboard/mining_dashboard/web/views.py
@@ -1521,16 +1521,16 @@ def xvb_current_tier_key(metrics, tiers):
_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)
+# Measured delivery PRIOR for boxes with no local measurement (#872): the fraction of the
+# advertised prize a winner's wallet actually receives, measured ON-CHAIN (p2pool.observer,
+# all three sidechains) across 25 audited won rounds, Jun–Aug 2026: point 0.33, bootstrap 95%
+# CI (0.28, 0.39). At most a small margin effect (a controlled experiment pinning the credited
+# margin at 2.1–2.5x the round minimum measured +5pp with overlapping CIs), so ONE band serves
+# every tier and regime. Payout of delivered work measured complete, so delivery == realization.
+# Supersedes the earlier (0.24, 0.42) two-era payout-window band, whose upper endpoint did not
+# survive on-chain recount. A local measurement (xvb_realization) still supersedes this prior.
+# ponytail: single-wallet study constant — recalibrate from the public-winners generalization.
+XVB_REALIZATION_PRIOR = (0.28, 0.39)
def xvb_forecast_tier_key(metrics, tiers):
@@ -1806,7 +1806,7 @@ def build_xvb_calc(metrics, state_mgr, realization=None):
cumulative forecast) and ``players_avg`` (which also makes a single-qualifier artifact like
Mega's self-evident). ``realized_reward_year`` scales the published figure by this wallet's
measured win realization (``realization``, from ``xvb_realization``) — None when unmeasured,
- so the client falls back to face value and says so. Returns ``{"enabled": False}`` alone when
+ so the client falls back to the study band; face value shows only in its own column. Returns ``{"enabled": False}`` alone when
XvB is off — there is no tier to calculate."""
if not metrics.xvb_enabled:
return {"enabled": False}
diff --git a/build/dashboard/tests/frontend/components.test.mjs b/build/dashboard/tests/frontend/components.test.mjs
index 6fd72f40..36d85c93 100644
--- a/build/dashboard/tests/frontend/components.test.mjs
+++ b/build/dashboard/tests/frontend/components.test.mjs
@@ -593,145 +593,71 @@ test('EarningsCard provenance line reflects the live price feed (#520)', () => {
assert.match(html, /USD 0\.000400/); // tiny XTM price keeps its precision
});
-test('XvB comparison dropdown shows Expected/Cost/Net per tier, degrades on a stale estimate (#118)', () => {
+test('XvB decision table: all tiers at once, study column, coloured net verdict (#872)', () => {
const base = clone();
base.earnings.available = true;
- base.earnings.coeff_day = 1e-7; // XMR per H/s per day → cost = threshold × this × 365
- // A Whale-capable what-if default (200k × 0.85 ≥ 100k threshold) so the target tier's Net shows;
- // the unsustainable path is asserted separately below.
+ base.earnings.coeff_day = 1e-7;
base.earnings.p2pool_hr = 200000;
base.earnings.p2pool_hr_str = '200.00 kH/s';
base.xvb_calc = {
- enabled: true,
- max_fraction: 0.85,
- estimates_available: true,
- estimates_stale: false,
- current_tier: 'None',
- target_tier: 'Whale (100.00 kH/s+)',
- target_threshold: 100000,
- sustainable: true,
- note: 'An XvB tier is raffle status, not an XMR payout.',
- mode_note: null,
- tiers: [
- { name: 'Donor (1.00 kH/s+)', threshold: 1000, expected_reward_year: 0.06 },
- { name: 'Vip (10.00 kH/s+)', threshold: 10000, expected_reward_year: 0.81 },
- { name: 'Whale (100.00 kH/s+)', threshold: 100000, expected_reward_year: 6.17 },
- { name: 'Mega (1.00 MH/s+)', threshold: 1000000, expected_reward_year: 56.9 },
- ],
- };
- const up = renderApp({ state: base });
- // The dropdown renders all four tiers as options.
- assert.match(up, /id="xvb-tier-select"/);
- for (const name of ['Donor', 'Vip', 'Whale', 'Mega']) {
- assert.match(up, new RegExp(`