diff --git a/dashboard/README.md b/dashboard/README.md index 7838816..536408a 100644 --- a/dashboard/README.md +++ b/dashboard/README.md @@ -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 diff --git a/dashboard/public/style.css b/dashboard/public/style.css index 8b918d5..e54ba56 100644 --- a/dashboard/public/style.css +++ b/dashboard/public/style.css @@ -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
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
diff --git a/dashboard/server.js b/dashboard/server.js
index 7f7356c..f216cb1 100644
--- a/dashboard/server.js
+++ b/dashboard/server.js
@@ -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();
});
@@ -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,
});
diff --git a/dashboard/test/about-numbers.test.js b/dashboard/test/about-numbers.test.js
new file mode 100644
index 0000000..e56e175
--- /dev/null
+++ b/dashboard/test/about-numbers.test.js
@@ -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 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/);
+});
diff --git a/dashboard/views/index.ejs b/dashboard/views/index.ejs
index 905ec15..2a4663e 100644
--- a/dashboard/views/index.ejs
+++ b/dashboard/views/index.ejs
@@ -13,50 +13,7 @@
<% } %>
-
- About the numbers on this page
-
- This is the PPS-classic build of simplepool,
- paying out in Thunder
- (BIP300 drivechain sidechain #9). Every accepted share
- credits your worker a fixed number of sats — currently
- 1 000 sats × share difficulty — whether
- or not that share becomes a block. Accruals accumulate under
- your Thunder address in pps_credits.
-
-
- 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.
-
-
- 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.
-
-
- Connect a miner
- <%= stratumUrl %>
-username: <your-Thunder-address>[.<rig_label>]
-password: (ignored — any value)
-
- The username must be a valid Thunder address — base58 of a 20-byte
- hash, e.g. 3Z6z1hPySNkFeB7HiKPCgqu4TZez. Generate one
- with thunder-cli get-new-address on any Thunder node.
- Using a Bitcoin address is rejected with
- "invalid thunder address" and no shares accrue.
- The optional .<rig_label> groups multiple rigs
- paying the same address as separate rows on the per-worker page.
-
-
+ <%- include('partial/about-numbers') %>
Bitcoin node tip
diff --git a/dashboard/views/partial/about-numbers.ejs b/dashboard/views/partial/about-numbers.ejs
new file mode 100644
index 0000000..a1c7baf
--- /dev/null
+++ b/dashboard/views/partial/about-numbers.ejs
@@ -0,0 +1,186 @@
+<%# What the figures on the overview mean, and how to point a rig at this pool.
+
+ Branches on pool_mode because almost nothing here is shared between the
+ two modes: solo pays the finder in the coinbase and accrues nothing,
+ pps-classic credits every share and settles through a pool wallet and a
+ BIP300 deposit. The card used to state the pps-classic story
+ unconditionally, which on a solo pool told miners to authorize with a
+ Thunder address — the one thing that cannot work there (stratum.c
+ rejects it with "invalid payout address in stratum username").
+
+ Every figure comes from res.locals.pool, i.e. from pool_meta, i.e. from
+ the running proxy. Nothing here is a literal, because the previous
+ literal — "1 000 sats × share difficulty" — was never true of a rate
+ that is derived per template and moves with difficulty.
+
+ Locals: pool, stratumUrl, sidechainId. %>
+<%
+const _p = (typeof pool !== 'undefined' && pool) ? pool : null;
+const _mode = _p && _p.pool_mode ? _p.pool_mode : null;
+const _net = _p && _p.network ? _p.network : null;
+const _url = (typeof stratumUrl !== 'undefined' && stratumUrl)
+ ? stratumUrl : 'stratum+tcp://:3334';
+const _sid = (typeof sidechainId !== 'undefined' && sidechainId != null)
+ ? sidechainId : 9;
+const _feePct = _p && _p.fee_bps ? (_p.fee_bps / 100).toFixed(2) + '%' : null;
+const _sats = n => Number(n).toLocaleString('en-US',
+ { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+
+/* Address examples for the network this pool is actually on — a miner told
+ * to use "bc1q…" on a signet pool has been misled just as surely as one told
+ * to use a Thunder address in solo mode.
+ *
+ * Only for networks the encoding pins down. "test/signet/regtest" comes from
+ * a base58 operator address, which genuinely cannot say which — so it falls
+ * through to prose rather than inventing a prefix. */
+function _examples(net) {
+ if (net === 'main') return { w: 'bc1q…', t: 'bc1p…', l: '1…', s: '3…' };
+ if (net === 'regtest') return { w: 'bcrt1q…', t: 'bcrt1p…', l: 'm… / n…', s: '2…' };
+ if (net === 'test' || net === 'signet' || net === 'test/signet')
+ return { w: 'tb1q…', t: 'tb1p…', l: 'm… / n…', s: '2…' };
+ return null;
+}
+const _ex = _examples(_net);
+%>
+
+ About the numbers on this page
+
+<% if (_mode === 'pps-classic') { %>
+
+ This pool runs in pps-classic mode, paying out in
+ Thunder (BIP300 drivechain sidechain #<%= _sid %>).
+ Every accepted share credits your worker whether or not that share
+ becomes a block. Credits accumulate under your Thunder address in
+ pps_credits.
+
+
+
+ The rate is not a fixed number. It is derived from
+ each block template as
+ (block value ÷ network difficulty) × (1 − fee), so it
+ moves with difficulty and with the fees in the template.
+ <% if (_p && _p.rate_sats_per_diff > 0) { %>
+ Right now: <%= _sats(_p.rate_sats_per_diff) %> sats
+ per unit of share difficulty<% if (_feePct) { %>, net of the
+ <%= _feePct %> operator fee<% } %><% if (_p.gross_sats_per_diff > 0) { %>
+ (gross <%= _sats(_p.gross_sats_per_diff) %>)<% } %>.
+ <% } else { %>
+ The proxy has not published a rate yet — it does so on its first
+ block template, so this fills in once the pool is receiving work.
+ <% } %>
+ Every credit is stored with the exact rate it was paid at, so you can
+ recompute your own balance on your worker page rather than take the
+ pool's word for it.
+
+
+ <% if (_p && _p.rate_source === 'override') { %>
+
+ ⚠ The operator has pinned this rate rather than
+ deriving it. A pinned rate does not track difficulty, and is taken
+ already net of fee — what it actually implies here is a
+ <%= (_p.effective_fee_bps / 100).toFixed(2) %>% fee<%
+ if (_feePct) { %>, against the <%= _feePct %> configured<% } %>.
+ See /health.
+
+ <% } %>
+
+
+ When a share becomes a block, the coinbase pays the pool's own
+ Bitcoin wallet<% if (_p && _p.pool_btc_address) { %>
+ (<%= _p.pool_btc_address %>)<% } %>
+ for the miner-share of the reward, and the operator address<% if (_p && _p.operator_address) { %>
+ (<%= _p.operator_address %>)<% } %>
+ for the fee<% if (_feePct) { %> of <%= _feePct %><% } %>. The 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.
+
+
+
+ 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.
+
+
+ Connect a miner
+ <%= _url %>
+username: <your-Thunder-address>[.<rig_label>]
+password: (ignored — any value)
+
+ The username must be a valid Thunder address — base58 of a 20-byte
+ hash, e.g. 3Z6z1hPySNkFeB7HiKPCgqu4TZez. Generate one
+ with thunder-cli get-new-address on any Thunder node.
+ Using a Bitcoin address is rejected with
+ "invalid thunder address" and no shares accrue.
+ The optional .<rig_label> groups multiple rigs
+ paying the same address as separate rows on the per-worker page.
+
+
+<% } else if (_mode === 'solo') { %>
+
+ This pool runs in solo mode. Each block it finds pays
+ the miner who found it, directly in that block's coinbase. Nothing
+ accrues between blocks: a share that does not become a block earns
+ nothing — it only proves you are working and sets your difficulty.
+ There is no PPS credit, no pool wallet and no Thunder payout in this
+ mode.
+
+
+
+ When one of your shares does become a block, the coinbase pays
+ your address the block reward<% if (_feePct) { %>
+ minus the operator fee of <%= _feePct %><% if (_p && _p.operator_address) { %>,
+ which goes to <%= _p.operator_address %><% } %><% } %>.
+ Coinbase outputs mature after 100 confirmations
+ (roughly 16 hours), so a block shows up here well before it is
+ spendable in your wallet.<% if (_feePct) { %> If the fee would come to
+ under 546 sats it is dropped as dust and you take the whole reward.<% } %>
+
+
+ Connect a miner
+ <%= _url %>
+username: <your-bitcoin-address>[.<rig_label>]
+password: (ignored — any value)
+
+ The username must be a Bitcoin address this pool can build an output
+ for:
+ <% if (_ex) { %>
+ P2WPKH (<%= _ex.w %>),
+ P2PKH (<%= _ex.l %>) or
+ P2SH (<%= _ex.s %>) on
+ <%= _net %>. Taproot (<%= _ex.t %>) is not
+ supported
+ <% } else { %>
+ P2WPKH, P2PKH or
+ P2SH. Taproot is not supported
+ <% } %>
+ and is rejected at authorize, as is a Thunder address
+ — that is the other mode. Both fail with
+ "invalid payout address in stratum username".
+ The optional .<rig_label> groups multiple rigs
+ paying the same address as separate rows on the per-worker page.
+
+
+<% } else { %>
+ <%# Same rule as the identity strip: an unknown mode gets prose that is
+ true either way, never a guess. The two modes differ on what a share
+ is worth and on what the username must be, so guessing wrong here
+ costs a miner real time. %>
+
+ This pool has not published its mode yet, and the two differ in ways
+ that change how you connect: solo pays the finder
+ directly in the coinbase and takes a Bitcoin address as the stratum
+ username, while pps-classic credits every accepted
+ share and takes a Thunder address. Restart the proxy to publish which
+ one this is, or ask the operator before pointing a rig at it.
+
+ Connect a miner
+ <%= _url %>
+username: <address>[.<rig_label>] (see above)
+password: (ignored — any value)
+<% } %>
+