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
28 changes: 28 additions & 0 deletions dashboard/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,34 @@ A non-mainnet pool is flagged with a warn-coloured rule, because "why has my
payout not arrived" and "this pool is mining signet" are frequently the same
question.

## "About the numbers on this page"

The explanatory card on `/` branches on `pool_mode`, because almost nothing
in it is shared between the modes:

| | `solo` | `pps-classic` |
| --- | --- | --- |
| A share that isn't a block | worth nothing | credited at the live rate |
| Block reward goes to | the finder, in the coinbase | the pool's BTC wallet |
| Stratum username | a **Bitcoin** address (P2WPKH / P2PKH / P2SH — **not** taproot) | a **Thunder** address |
| Rejection if you get it wrong | `invalid payout address in stratum username` | `invalid thunder address` |

That last row is why this is not cosmetic. `src/stratum.c` branches on
`pps_enabled` at authorize, so the card's instructions are load-bearing: a
solo pool that tells miners to use a Thunder address is telling them to do
the one thing that cannot work.

Every figure comes from `pool_meta` — rate, gross, fee, operator address,
pool wallet, network — and the address examples follow the pool's network, so
a signet pool shows `tb1q…` rather than `bc1q…`. Nothing in the card is a
literal. The version this replaced hardcoded *"1 000 sats × share
difficulty"*, which was never true of a rate that is derived per template and
moves with difficulty; a pinned rate (`rate_source = override`) is now called
out with the fee it actually implies.

Unknown mode gets prose naming both, and no username form — same rule as the
identity strip, since guessing wrong costs a miner real time.

## Build provenance — `/api/versions`

Answers "which commit is this pool actually running?" for simplepool, the
Expand Down
14 changes: 14 additions & 0 deletions dashboard/public/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ a:hover { text-decoration: underline; }
.brand { font-weight: 700; font-size: 18px; color: var(--fg); text-decoration: none; }
.tag { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.1em; }

/* "About the numbers" card. The <h3>/<pre> styling used to be inline style=
* attributes on the one hardcoded copy; it moved here when the card became a
* partial with three bodies, rather than being repeated three times. */
.about-h3 { margin-top: 1em; margin-bottom: 0.25em; }
.about-pre {
margin: 0;
padding: 0.5em;
background: #0002;
border-radius: 4px;
overflow-x: auto;
font-size: 12px;
}
.about-note { margin-top: 0.5em; }

/* Pool identity strip — sits between the header and the health banner on
* every page. Deliberately quiet: it is reference material a miner reads
* once and then ignores, not an alert. The one exception is a non-mainnet
Expand Down
6 changes: 5 additions & 1 deletion dashboard/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ app.use((_req, res, next) => {
* reading it live means a proxy restart onto a different network shows
* up on the next refresh instead of on the next dashboard restart. */
res.locals.pool = stats.poolMeta(db);
/* The about-numbers card needs both to tell a miner how to connect, and
* it is a partial rather than an index-only block, so they live here
* rather than being threaded through one render call. */
res.locals.stratumUrl = PUBLIC_STRATUM_URL;
res.locals.sidechainId = THUNDER_SIDECHAIN_ID;
next();
});

Expand Down Expand Up @@ -148,7 +153,6 @@ app.get('/', (_req, res) => {
const node = stats.nodeStatus(db);
res.render('index', {
ov, lb, lbAddr, blocks, node,
stratumUrl: PUBLIC_STRATUM_URL,
fmtHashrate: stats.fmtHashrate,
fmtBtc: stats.fmtBtc,
});
Expand Down
174 changes: 174 additions & 0 deletions dashboard/test/about-numbers.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/* The "about the numbers" card.
*
* The card it replaces stated the pps-classic story unconditionally, which
* on a solo pool told miners to authorize with a Thunder address — rejected
* by stratum.c with "invalid payout address in stratum username". So these
* tests are mostly about the card not saying the other mode's thing: what a
* share is worth and what the username must be are exactly the two facts
* that differ, and both cost a miner real time when stated wrongly.
*
* The rate is asserted to be the live one, never a literal. The literal it
* replaces ("1 000 sats × share difficulty") was hardcoded HTML describing a
* rate that is derived per template and moves with difficulty.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import fs from 'node:fs';
import os from 'node:os';
import ejs from 'ejs';
import Database from 'better-sqlite3';

import * as fmt from '../lib/fmt.js';
import * as stats from '../lib/stats.js';
import { openDb } from '../lib/db.js';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const VIEWS = path.resolve(__dirname, '../views');

const OPERATOR = 'tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx';
const POOL_BTC = 'tb1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3';
const URL_ = 'stratum+tcp://pool.example.org:3334';

const PPS = {
pool_mode: 'pps-classic', fee_bps: 100, network: 'signet',
rate_source: 'derived', rate_sats_per_diff: 2783.22,
gross_sats_per_diff: 2811.33, effective_fee_bps: 100,
operator_address: OPERATOR, pool_btc_address: POOL_BTC,
};
const SOLO = {
pool_mode: 'solo', fee_bps: 100, network: 'signet',
rate_source: 'derived', rate_sats_per_diff: 0, gross_sats_per_diff: 2811.33,
effective_fee_bps: 0, operator_address: OPERATOR, pool_btc_address: null,
};

const card = (pool, extra = {}) =>
ejs.renderFile(path.join(VIEWS, 'partial/about-numbers.ejs'),
{ ...fmt.all, pool, stratumUrl: URL_, sidechainId: 9, ...extra },
{ views: [VIEWS] });

test('pps-classic states the live rate, not a hardcoded one', async () => {
const html = await card(PPS);
assert.match(html, /pps-classic/);
assert.match(html, /2,783\.22/, 'the rate the proxy actually published');
assert.match(html, /2,811\.33/, 'gross, before fee');
assert.match(html, /1\.00%/);
/* The literal this card exists to remove. */
assert.doesNotMatch(html, /1[ ]000[ ]sats/);
assert.match(html, /not a fixed number/);
});

test('pps-classic names the Thunder username and both addresses', async () => {
const html = await card(PPS);
assert.match(html, /your-Thunder-address/);
assert.match(html, /invalid thunder address/);
assert.ok(html.includes(POOL_BTC));
assert.ok(html.includes(OPERATOR));
assert.ok(html.includes(URL_));
/* Must not tell a pps miner to use a Bitcoin address. */
assert.doesNotMatch(html, /your-bitcoin-address/);
});

test('solo says nothing about Thunder, deposits or PPS credit', async () => {
const html = await card(SOLO);
assert.match(html, /\bsolo\b/);
/* None of the pps-classic machinery exists in solo. */
assert.doesNotMatch(html, /pps_credits/);
assert.doesNotMatch(html, /BIP300 deposit/);
assert.doesNotMatch(html, /payout worker/);
assert.doesNotMatch(html, /sidechain/);
/* Thunder appears exactly twice, and both are negations: "there is no
* Thunder payout in this mode", and "a Thunder address is rejected".
* Both earn their place — a miner arriving from a pps-classic pool, or
* from the card this replaces, needs to be told. The count is pinned so
* copy drift cannot quietly reintroduce the pps-classic story here. */
assert.equal((html.match(/Thunder/g) || []).length, 2);
assert.match(html, /no pool wallet and no Thunder payout/);
assert.match(html, /as is a <strong>Thunder<\/strong> address/);
/* Never as an instruction. */
assert.doesNotMatch(html, /your-Thunder-address/);
/* No pool wallet in solo, so its address must not appear. */
assert.ok(!html.includes(POOL_BTC));
});

test('solo tells miners to use a bitcoin address, and says taproot is not', async () => {
const html = await card(SOLO);
assert.match(html, /your-bitcoin-address/);
assert.doesNotMatch(html, /your-Thunder-address/);
assert.match(html, /P2WPKH/);
assert.match(html, /[Tt]aproot.*not\s*<\/strong>?\s*supported|not\s*<\/strong>\s*and is rejected|Taproot/);
assert.match(html, /invalid payout address in stratum username/);
/* Coinbase maturity — the "why can't I spend it" question. */
assert.match(html, /100 confirmations/);
assert.ok(html.includes(OPERATOR), 'solo still pays an operator fee');
});

test('address examples follow the network the pool is actually on', async () => {
assert.match(await card({ ...SOLO, network: 'main' }), /bc1q…/);
assert.match(await card({ ...SOLO, network: 'main' }), /bc1p…/);
assert.match(await card({ ...SOLO, network: 'signet' }), /tb1q…/);
assert.match(await card({ ...SOLO, network: 'regtest' }), /bcrt1q…/);
/* A mainnet example on a signet pool misleads as surely as the wrong
* address type does. */
assert.doesNotMatch(await card({ ...SOLO, network: 'signet' }), /bc1q…/);
});

test('an ambiguous network falls back to prose instead of inventing a prefix', async () => {
/* "test/signet/regtest" comes from a base58 operator address, which
* genuinely cannot say which chain it is. */
const html = await card({ ...SOLO, network: 'test/signet/regtest' });
assert.match(html, /P2WPKH/);
assert.doesNotMatch(html, /1q…/, 'no bech32 prefix invented');
});

test('a pinned rate is called out, with the fee it actually implies', async () => {
const html = await card({ ...PPS, rate_source: 'override', effective_fee_bps: 644.3 });
assert.match(html, /pinned/);
assert.match(html, /6\.44%/);
assert.match(html, /\/health/);
});

test('pps-classic before the first template does not claim a rate of zero', async () => {
const html = await card({ ...PPS, rate_sats_per_diff: 0, gross_sats_per_diff: 0 });
assert.match(html, /has not published a rate yet/);
assert.doesNotMatch(html, /0\.00\s*sats/);
});

test('an unknown mode describes both and commits to neither', async () => {
for (const pool of [null, { pool_mode: null, fee_bps: 0 }]) {
const html = await card(pool);
assert.match(html, /has not published its mode/);
/* Both named, so a miner knows what to ask the operator — but no
* username form is asserted, because guessing costs them time. */
assert.match(html, /solo/);
assert.match(html, /pps-classic/);
assert.doesNotMatch(html, /your-Thunder-address/);
assert.doesNotMatch(html, /your-bitcoin-address/);
}
});

test('the overview still renders end to end with the partial in place', async () => {
/* Against real stats output rather than a hand-written `ov`, so this
* catches the partial breaking the page it lives on without going stale
* every time the overview grows a field. */
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'sp-about-')), 'shares.db');
const sdb = new Database(file);
sdb.exec(fs.readFileSync(path.resolve(__dirname, '../../schema.sql'), 'utf8'));
sdb.close();
const db = openDb(file);

const html = await ejs.renderFile(path.join(VIEWS, 'index.ejs'), {
...fmt.all, pool: PPS, stratumUrl: URL_, sidechainId: 9,
health: null, active: 'overview',
ov: stats.overview(db),
lb: stats.leaderboard(db),
lbAddr: stats.leaderboardByAddress(db),
blocks: stats.recentBlocks(db, 5),
node: stats.nodeStatus(db),
fmtHashrate: stats.fmtHashrate,
fmtBtc: stats.fmtBtc,
}, { views: [VIEWS] });
assert.match(html, /About the numbers on this page/);
assert.match(html, /2,783\.22/);
});
45 changes: 1 addition & 44 deletions dashboard/views/index.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -13,50 +13,7 @@
</div>
<% } %>

<section class="card info">
<h2>About the numbers on this page</h2>
<p class="muted small">
This is the <strong>PPS-classic build</strong> of simplepool,
paying out in <strong>Thunder</strong>
(BIP300 drivechain sidechain&nbsp;#9). Every accepted share
credits your worker a fixed number of sats — currently
<strong>1&nbsp;000&nbsp;sats × share difficulty</strong> — whether
or not that share becomes a block. Accruals accumulate under
your Thunder address in <code>pps_credits</code>.
</p>
<p class="muted small">
When a share becomes a block, the coinbase pays the pool's own
Bitcoin wallet (a normal P2WPKH output) for the miner-share of
the reward, and the operator address for a small fee (1%). The
pool operator then periodically batches accumulated BTC into
Thunder via a canonical BIP300 deposit transaction — this is
the step that actually moves value onto the sidechain. Once
the pool's Thunder reserve is funded, the payout worker sends
Thunder transactions to each miner's Thunder address on a
cadence to drain their owed balance.
</p>
<p class="muted small">
Why the two-step deposit? The LayerTwo-Labs enforcer only
credits deposits that spend real, spendable UTXOs. Coinbases
don't qualify; embedding the deposit directly in the coinbase
results in a stranded output. Standard drivechain mining
pools all route through a pool BTC wallet.
</p>

<h3 style="margin-top:1em;margin-bottom:0.25em">Connect a miner</h3>
<pre class="mono small" style="margin:0;padding:0.5em;background:#0002;border-radius:4px;overflow-x:auto"><%= stratumUrl %>
username: &lt;your-Thunder-address&gt;[.&lt;rig_label&gt;]
password: (ignored — any value)</pre>
<p class="muted small" style="margin-top:0.5em">
The username must be a valid Thunder address — base58 of a 20-byte
hash, e.g. <code>3Z6z1hPySNkFeB7HiKPCgqu4TZez</code>. Generate one
with <code>thunder-cli get-new-address</code> on any Thunder node.
Using a Bitcoin address is rejected with
<em>"invalid thunder address"</em> and no shares accrue.
The optional <code>.&lt;rig_label&gt;</code> groups multiple rigs
paying the same address as separate rows on the per-worker page.
</p>
</section>
<%- include('partial/about-numbers') %>

<section class="card">
<h2>Bitcoin node tip</h2>
Expand Down
Loading
Loading