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. +

+
+ + + + + + + + + + + ${rows.map((r) => { + const est = r.yours !== null ? [r.yours, r.yours] : r.study; + return html` + + + + + + + `; + })} + +
TierOdds / 30dCost / yrXvB says / yr${estHeader}Net / yr
${r.name}${r.sustainable ? "" : " ⚠"}${r.oddsPer30d ? `≈ ${Number(r.oddsPer30d.toPrecision(2))} wins · ${Number((r.players || 0).toPrecision(2))} players` : "—"}${r.cost !== null ? formatXmr(r.cost) : "—"}${r.xvbSays !== null ? formatXmr(r.xvbSays) : "—"}${fmtRange(est)}${r.sustainable ? fmtRange(r.net) : "—"}
${ - // 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`

- Draw: ≈ ${Number((sel.win_odds_day * 30).toPrecision(2))} wins / 30d - among ~${Number(sel.players_avg.toPrecision(2))} qualifiers

` - : 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`

- ≈ ${calc.estimates_available ? formatFiat(coinFiat(cmp.expected, energy.xmr_price), energy.currency) : "—"} expected · - ${formatFiat(coinFiat(cmp.cost, energy.xmr_price), energy.currency)} cost · - ${sustainable ? formatFiat(coinFiat(netShown, energy.xmr_price), energy.currency) : "—"} net, per year -

` - : null - } - ${ - !sustainable - ? 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} />

${calc.note}${calc.mode_note ? " " + calc.mode_note : ""}

`; } 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(`]*>${name}`), `missing tier option: ${name}`); - } - // Default selection = the target tier (Whale): Expected is XvB's figure, Cost = 100000 × 1e-7 × - // 365 = 3.65 XMR/yr, Net = 6.17 − 3.65 = 2.52. - assert.match(up, /Expected \(XvB\)/); - assert.match(up, /6\.1700 XMR/); // expected - assert.match(up, /3\.6500 XMR/); // cost - assert.match(up, /2\.5200 XMR/); // net - assert.match(up, /From XvB's published per-tier estimate/); - assert.doesNotMatch(up, /estimate unavailable/); - - // Stale/unavailable estimate: the note replaces the Expected number, cost still shows. - const stale = clone(); - stale.earnings.available = true; - stale.earnings.coeff_day = 1e-7; - stale.xvb_calc = { - ...base.xvb_calc, - estimates_available: false, - estimates_stale: true, - tiers: base.xvb_calc.tiers.map((t) => ({ ...t, expected_reward_year: null })), - }; - const sHtml = renderApp({ state: stale }); - assert.match(sHtml, /estimate unavailable/); - assert.match(sHtml, /Expected reward estimate unavailable/); - assert.match(sHtml, /3\.6500 XMR/); // cost still stands - - // Unsustainable tier: at the fixture's small default hashrate (~8 kH/s), the Whale target - // can't be held (8k × 0.85 < 100k) — the comparison must SAY so and withhold the Net rather - // than imply an unreachable +2.52 XMR/yr payout. - const small = clone(); - small.earnings.available = true; - small.earnings.coeff_day = 1e-7; - small.xvb_calc = { ...base.xvb_calc }; - const uHtml = renderApp({ state: small }); - assert.match(uHtml, /Not sustainable at your hashrate/); - assert.match(uHtml, /6\.1700 XMR/); // XvB's expected figure still shown (it's their number) - assert.doesNotMatch(uHtml, /2\.5200 XMR/); // but no reachable-looking Net -}); - -test('XvB tier comparison prefers the measured net and shows the draw behind it (#872)', () => { - const base = clone(); - base.earnings.available = true; - base.earnings.coeff_day = 1e-7; - base.earnings.p2pool_hr = 200000; // what-if default: Whale sustainable (200k × 0.85 > 100k) - 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: 'An XvB tier is raffle status, not an XMR payout.', - mode_note: null, - realization_pct: 19, - realization_wins: 15, + 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, realization_pct: null, realization_wins: null, tiers: [ + { name: 'Vip (10.00 kH/s+)', threshold: 10000, expected_reward_year: 0.81, + realized_reward_year: null, assumed_reward_year_range: [0.81 * 0.27, 0.81 * 0.38], + win_odds_day: 0.12, players_avg: 31.4 }, { name: 'Whale (100.00 kH/s+)', threshold: 100000, expected_reward_year: 6.17, - realized_reward_year: 6.17 * 0.19, win_odds_day: 0.84, players_avg: 8.2 }, + realized_reward_year: null, assumed_reward_year_range: [6.17 * 0.27, 6.17 * 0.38], + win_odds_day: 0.84, players_avg: 8.2 }, + { name: 'Mega (1.00 MH/s+)', threshold: 1000000, expected_reward_year: 56.9, + realized_reward_year: null, assumed_reward_year_range: [56.9 * 0.27, 56.9 * 0.38], + win_odds_day: 9.4, players_avg: 1.0 }, ], }; const up = renderApp({ state: base }); - // Measured: the label says so, the value is realized − cost (1.1723 − 3.65 = −2.4777) — - // the face-value +2.52 must NOT render as the net (#872: it had the wrong sign). - assert.match(up, /Net \/ yr \(measured\)/); - assert.match(up, /-2\.4777\d* XMR/); - assert.doesNotMatch(up, /2\.5200 XMR/); - assert.match(up, /19% of face value over the last 15 wins/); - // The draw line: odds over 30d and the qualifier count. - assert.match(up, /≈ 25 wins \/ 30d/); - assert.match(up, /~8\.2 qualifiers/); - - // Unmeasured: face value stands but is LABELED face value, and no draw line without odds. - base.xvb_calc.realization_pct = null; - base.xvb_calc.realization_wins = null; - base.xvb_calc.tiers[0].realized_reward_year = null; - base.xvb_calc.tiers[0].win_odds_day = null; - const fv = renderApp({ state: base }); - assert.match(fv, /Net \/ yr \(face value\)/); - assert.match(fv, /2\.5200 XMR/); - assert.doesNotMatch(fv, /id="xvb-draw-line"/); -}); - -test('XvB tier comparison shows the estimated band on an unmeasured box (#872)', () => { + // One table, no dropdown, every tier a row with odds, both estimates and a verdict. + assert.match(up, /id="xvb-decision-table"/); + assert.doesNotMatch(up, /id="xvb-tier-select"/); + assert.match(up, /XvB says \/ yr/); + assert.match(up, /Study est\. \/ yr/); + assert.match(up, /winners receiving 33% of face/); + // Whale row: cost 3.65; study band 1.6659…2.3446; net band negative at both ends -> red. + assert.match(up, /1\.6659[0-9]* XMR … 2\.3446[0-9]* XMR/); + assert.match(up, /-1\.98[0-9]* XMR … -1\.30[0-9]* XMR/); + // Mega is unsustainable at 200k×0.85: flagged, net withheld. + assert.match(up, /Mega \(1\.00 MH\/s\+\) ⚠/); + // Draw odds render per row. + assert.match(up, /≈ 25 wins · 8\.2 players/); + // XvB's face value stays visible as XvB's own number. + assert.match(up, /6\.1700 XMR/); +}); + +test('XvB decision table: local measured wins supersede the study column (#872)', () => { const base = clone(); base.earnings.available = true; 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, estimates_available: true, estimates_stale: false, max_fraction: 0.85, + enabled: true, max_fraction: 0.85, estimates_available: true, estimates_stale: false, 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, + realization_pct: 32, realization_wins: 9, 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], + realized_reward_year: 6.17 * 0.32, assumed_reward_year_range: null, 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/); + const up = renderApp({ state: base }); + assert.match(up, /Yours \(32% × 9 wins\)/); + assert.match(up, /1\.9744[0-9]* XMR/); // 6.17 × 0.32 + assert.match(up, /-1\.6756[0-9]* XMR/); // net = 1.9744 − 3.65, single point }); + 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(); @@ -753,6 +679,44 @@ test('CadenceCard shows the — placeholders on a cold stack, real figures when assert.match(up, /1,234,567/); }); +test('XvB decision table: verdict colours cover green and zero-spanning bands, stale degrades honestly (#872)', () => { + const base = clone(); + base.earnings.available = true; + base.earnings.coeff_day = 1e-9; // tiny cost so a positive band is constructible + 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: 'Vip (10.00 kH/s+)', target_threshold: 10000, + sustainable: true, note: 'raffle status', mode_note: null, + realization_pct: null, realization_wins: null, + tiers: [ + // cost = 10000 × 1e-9 × 365 = 0.00365; band well above -> green at both ends + { name: 'Vip (10.00 kH/s+)', threshold: 10000, expected_reward_year: 0.81, + realized_reward_year: null, assumed_reward_year_range: [0.22, 0.31], + win_odds_day: 0.12, players_avg: 31.4 }, + // band straddles cost -> neutral (no class) + { name: 'Whale (100.00 kH/s+)', threshold: 100000, expected_reward_year: 6.17, + realized_reward_year: null, assumed_reward_year_range: [0.03, 0.05], + win_odds_day: 0.84, players_avg: 8.2 }, + ], + }; + const up = renderApp({ state: base }); + assert.match(up, /class="status-ok">0\.21635/); // green: even pessimistic end profits + assert.match(up, /class="">-0\.0065/); // zero-spanning: neutral cell class + // Stale estimates: costs still render, estimate/net columns dash, footer says costs only. + const stale = clone(); + stale.earnings.available = true; + stale.earnings.coeff_day = 1e-7; + stale.earnings.p2pool_hr = 200000; + stale.xvb_calc = { ...base.xvb_calc, estimates_available: false, estimates_stale: true, + tiers: base.xvb_calc.tiers.map((t) => ({ ...t, expected_reward_year: null, + assumed_reward_year_range: null, realized_reward_year: null })) }; + const sh = renderApp({ state: stale }); + assert.match(sh, /tier costs only/); + assert.match(sh, /0\.365\d* XMR/); // vip cost at 1e-7 +}); + test('XvBStats greys the credited figures and flags the footer when the fetch is stale (#311)', () => { // Fresh (base fixture, xvb_stale false): the normal "Stats fetched" footer, no stale marks. const fresh = renderApp(); diff --git a/build/dashboard/tests/frontend/logic.test.mjs b/build/dashboard/tests/frontend/logic.test.mjs index 89a025d0..620f88c3 100644 --- a/build/dashboard/tests/frontend/logic.test.mjs +++ b/build/dashboard/tests/frontend/logic.test.mjs @@ -17,7 +17,7 @@ import { normalizeChoice, normalizeSort, loadPref, savePref, AVG_WINDOWS, DEFAULT_AVG_WINDOW, normalizeAvgWindow, heroKpis, raffleCls, - parseHashrate, fmtHashrate, computeEarnings, computeXvbTier, xvbTierComparison, formatXmr, formatXtm, formatTimeToShare, formatAgo, + parseHashrate, fmtHashrate, computeEarnings, computeXvbTier, xvbDecisionRows, formatXmr, formatXtm, formatTimeToShare, formatAgo, computeEnergy, formatFiat, formatFiatAmount, formatUnit, coinTriplet, coinFiat, formatFiatPrice, priceSourceLabel, DAYS_PER_MONTH, DAYS_PER_YEAR, @@ -347,67 +347,61 @@ test('computeXvbTier: null when disabled, calc missing, empty tiers, or bad hash assert.equal(computeXvbTier(null, XVB_CALC), null); }); -// --- xvbTierComparison (#118) — per-tier expected vs cost vs net ------------------------- +// --- xvbDecisionRows (#872) — the per-tier decision table's pure math ------------------- -test('xvbTierComparison: expected − cost = net when the estimate is present', () => { - const tier = { name: 'Whale', threshold: 100_000, expected_reward_year: 6.17 }; - const c = xvbTierComparison(tier, 1e-7); // cost = 100000 × 1e-7 × 365 = 3.65 - assert.equal(c.expected, 6.17); - assert.ok(Math.abs(c.cost - 3.65) < 1e-9); - assert.ok(Math.abs(c.net - 2.52) < 1e-9); -}); - -test('xvbTierComparison: null expected (stale) keeps cost, nulls net — never fabricates a reward', () => { - const tier = { name: 'Whale', threshold: 100_000, expected_reward_year: null }; - const c = xvbTierComparison(tier, 1e-7); - assert.equal(c.expected, null); - assert.ok(Math.abs(c.cost - 3.65) < 1e-9); - assert.equal(c.net, null); -}); - -test('xvbTierComparison: no coeff_day (network stats down) → cost and net null', () => { - const tier = { name: 'Whale', threshold: 100_000, expected_reward_year: 6.17 }; - const c = xvbTierComparison(tier, 0); - assert.equal(c.cost, null); - assert.equal(c.net, null); - assert.equal(c.expected, 6.17); -}); - -test('xvbTierComparison: measured realization yields realizedNet — the sign the face value got wrong (#872)', () => { - // Face value nets positive (6.17 − 3.65) while the measured figure nets NEGATIVE — the - // production case that motivated #872. Both are returned; the panel prefers realized. - const tier = { - name: 'Whale', threshold: 100_000, - expected_reward_year: 6.17, realized_reward_year: 6.17 * 0.19, - }; - const c = xvbTierComparison(tier, 1e-7); - assert.ok(c.net > 0); - assert.ok(Math.abs(c.realized - 1.1723) < 1e-9); - 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); -}); +const _CALC = { + enabled: true, max_fraction: 0.85, realization_pct: null, realization_wins: null, + tiers: [ + { name: 'Vip (10.00 kH/s+)', threshold: 10_000, expected_reward_year: 0.81, + realized_reward_year: null, assumed_reward_year_range: [0.81 * 0.27, 0.81 * 0.38], + win_odds_day: 0.12, players_avg: 31.4 }, + { name: 'Whale (100.00 kH/s+)', threshold: 100_000, expected_reward_year: 6.17, + realized_reward_year: null, assumed_reward_year_range: [6.17 * 0.27, 6.17 * 0.38], + win_odds_day: 0.84, players_avg: 8.2 }, + ], +}; -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); - assert.equal(c.realized, null); - assert.equal(c.realizedNet, null); - assert.ok(c.net !== null); // face-value net still stands, labeled as such by the panel +test('xvbDecisionRows: study band prices the measured delivery into the net verdict', () => { + const rows = xvbDecisionRows(_CALC, 1e-7, 200_000); // whale cost 3.65, vip cost 0.365 + const whale = rows[1]; + assert.equal(whale.mode, 'study'); + assert.ok(Math.abs(whale.cost - 3.65) < 1e-9); + assert.ok(Math.abs(whale.net[0] - (6.17 * 0.27 - 3.65)) < 1e-9); + assert.ok(Math.abs(whale.net[1] - (6.17 * 0.38 - 3.65)) < 1e-9); + assert.equal(whale.cls, 'status-bad'); // even the optimistic end loses + assert.equal(whale.sustainable, true); + assert.ok(Math.abs(whale.oddsPer30d - 25.2) < 1e-9); +}); + +test('xvbDecisionRows: a wallet\'s own measured wins supersede the study band', () => { + const calc = structuredClone(_CALC); + calc.realization_pct = 32; calc.realization_wins = 9; + calc.tiers[1].realized_reward_year = 6.17 * 0.32; + const whale = xvbDecisionRows(calc, 1e-7, 200_000)[1]; + assert.equal(whale.mode, 'yours'); + assert.ok(Math.abs(whale.net[0] - (6.17 * 0.32 - 3.65)) < 1e-9); + assert.equal(whale.net[0], whale.net[1]); // a point, not a band +}); + +test('xvbDecisionRows: unsustainable tiers are flagged; face-only falls back labeled', () => { + const rows = xvbDecisionRows(_CALC, 1e-7, 20_000); // whale needs 100k > 20k x 0.85 + assert.equal(rows[1].sustainable, false); + assert.equal(rows[0].sustainable, true); + // no band and no measured -> face-value mode, net still computed (component labels it) + const calc = structuredClone(_CALC); + calc.tiers[1].assumed_reward_year_range = null; + const whale = xvbDecisionRows(calc, 1e-7, 200_000)[1]; + assert.equal(whale.mode, 'face'); + assert.ok(Math.abs(whale.net[0] - (6.17 - 3.65)) < 1e-9); +}); + +test('xvbDecisionRows: no coeff (network stats down) -> no cost, no net, never a guess', () => { + const whale = xvbDecisionRows(_CALC, 0, 200_000)[1]; + assert.equal(whale.cost, null); + assert.equal(whale.net, null); + assert.equal(whale.mode, 'none'); + assert.equal(xvbDecisionRows({ enabled: false }, 1e-7, 200_000).length, 0); }); - test('formatXmr: precision scales with magnitude; "—" for null/invalid', () => { assert.equal(formatXmr(2.5), '2.5000 XMR'); // >= 1 -> 4 dp assert.equal(formatXmr(0.1234567), '0.123457 XMR'); // >= 0.001 -> 6 dp diff --git a/docs/dashboard.md b/docs/dashboard.md index 01741d6c..a1af6a4e 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -638,23 +638,18 @@ the comparison below shows them. | **Current Tier** | The tier your credited XvB donation clears right now (the lower of XvB's 1h and 24h averages). | | **Target Tier** | The tier the donation controller is configured to aim for (`xvb.donation_level`), flagged when your hashrate can't sustain it. | -Below the tier figures, a **per-tier payout comparison** dropdown weighs each donor tier three ways: +Below the tier figures sits the **per-tier decision table** — every donor tier on one row, so +the whole choice is visible at once: -| Field | Meaning | +| Column | Meaning | |---|---| -| **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). 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 -tier with one qualifier (it happens) is winner-take-all: its headline reward assumes that one -donor stays alone, and evaporates the moment a second qualifies. - -The estimate and the winners file are fetched over Tor on the same cadence and staleness rules as -the XvB stats card, so a quiet feed degrades to `estimate unavailable` (and the draw line -disappears) rather than showing an old number. Pick a tier to compare, e.g. Whale against VIP -Donor, at a glance. +| **Odds / 30d** | How often this tier's rounds pay out and among how many qualifiers, computed from XvB's public winners feed. The draw is random among qualifiers — donating above a threshold buys no extra odds. | +| **Cost / yr** | The P2Pool earnings given up by donating the tier threshold for a year, at your current rate. | +| **XvB says / yr** | XvB's own published expected reward — **face value**: it prices every bonus hash at full block reward. Shown as their number, never blended. | +| **Study est. / yr** | The same figure scaled by the **measured delivery band**: across 25 audited won rounds, verified on-chain across all three P2Pool sidechains (June–August 2026), winners received 33% of the advertised prize work (95% CI 28–39%; single-wallet on-chain audit, corroborated by a 14-winner public crawl), with at most a small margin effect. Once this box has enough measured wins of its own, the column becomes **Yours (N% × M wins)** and uses your wallet's measured figure instead. | +| **Net / yr** | The verdict: estimated reward minus the cost. **Red** when even the optimistic end of the band loses; **green** when even the pessimistic end profits; neutral when the band spans zero. Withheld (with a ⚠ on the tier) when your hashrate cannot sustain the tier — an unreachable payout must never look reachable. | + +A fiat line prices the best sustainable tier's net at your configured XMR price. Raffle mechanics, flat: the winner of a donor round is drawn at random among wallets above the tier threshold on both credited averages; a win terminates if the 1h average then drops below the