A small read-only Node.js dashboard for the simplepool solo-mining stratum server (the one with direct miner payouts + a small operator fee). It opens a snapshot of the proxy's SQLite database in read-only WAL mode and serves a public web page with hashrate, a worker leaderboard, recent blocks, a historical "blocks found by the pool" page, and a per-worker drilldown.
Each worker row in the leaderboard is a <bitcoin_address>.<rig_label>
pair (stratum username convention); the workers.payout_address column
lets future views roll up multiple rigs per address. The historical
blocks panel at /blocks reads blocks_found and shows finder,
payout address, on-chain reward, and operator fee for every block the
pool has ever solved.
- Node 20+
- The
simplepoolbinary writing to a SQLite file (default../data/shares.db) - A periodic snapshot at
../data/shares.snapshot.dbproduced viasqlite3 shares.db ".backup shares.snapshot.db"on a cron — see the proxy README.
The dashboard opens the snapshot read-only. SQLite's online backup is atomic, so the snapshot is always a consistent view of the live DB and the proxy is never blocked by dashboard queries. It is safe to point multiple dashboard instances at the same snapshot file.
cd simplepool/dashboard
npm install
cp .env.example .env
# edit .env if your DB path or port differs
npm start # production
npm run dev # auto-restart on file change
Defaults: PORT=8081, DASHBOARD_BIND=127.0.0.1 (set 0.0.0.0 to serve it
without a reverse proxy), PROXY_DB_PATH=../data/shares.snapshot.db,
COIN_SYMBOL=BTC (the unit block rewards are shown in; set it to the chain's
ticker on a forknet, e.g. COIN_SYMBOL=pECX).
If the snapshot file doesn't exist yet, the dashboard starts anyway and
displays "no data yet" until the first .backup produces it. You can also
point PROXY_DB_PATH at the live shares.db if you don't want to run a
snapshot cron — SQLite's WAL mode makes that safe too, just less isolated.
| Path | Description |
|---|---|
/ |
Overview, leaderboard, last 5 blocks |
/blocks |
Full historical "blocks found by the pool", paginated |
/blocks?before=<ts> |
Next page (older than the given UNIX timestamp) |
/worker/:name |
Per-worker drilldown |
/api/overview |
JSON |
/api/leaderboard |
JSON |
/api/node |
Mainchain tip and the network difficulty behind it |
/api/worker/:name |
JSON |
/api/blocks |
JSON paginated, ?limit=N&before=<ts> (default 50) |
/api/versions |
Build provenance of every component (see below) |
/api/status |
Everything at once: pool, node, health, versions |
/healthz |
{ ok: true, db_ready: bool } |
/health |
Full hard-failure detail; 503 when a check is failing |
/slipstream |
Slipstream fees and recent txs (only with SLIPSTREAM_API_URL) |
/api/status is the one URL to poll if you want a single document: pool
totals and hashrate, mainchain tip, the health checks, and which commit of
each component is running. It always returns 200 — it is a report, and a
report that a check is failing was still produced successfully. Watch
health.ok for the condition and /health for a status code to alert on.
Set SLIPSTREAM_API_URL (where this process reaches the slipstream
service, e.g. http://127.0.0.1:8124) and the
public nav gains a Slipstream page: the minimum submission rate, the
current mineable rate, how to submit, and each recent tx with where it
stands. PUBLIC_SLIPSTREAM_URL is the address shown to submitters.
The page reads the service's API, never slipstream.db, so the service stays
the only owner of its schema; if it is down, the page says so and the rest of
the dashboard is unaffected.
The node-tip card on / — and /api/node — report the chain's current
difficulty next to its height. It answers the question every other number on
the page depends on: what a share is worth under PPS, how often this pool
should expect a block, and whether a listener's promised min_diff is above
the chain at all.
It does not come from node_status. That table is the bitcoind tip poll
and carries no target. It comes from pool_meta.network_difficulty, which the
proxy rewrites from every block template it builds a job from
(refresh_pps_rate() in src/main.c derives it from the template's
target/bits) — the same row the PPS rate and the health checks read, so there
is one difficulty on this dashboard rather than two that can disagree.
Two consequences worth knowing:
- It carries its own timestamp, separate from the tip's. The tip poll and the template feed are different sources and go stale independently, so a difficulty frozen at yesterday's value next to a ticking height has to be visible as such. Hover the figure for the exact value and its "as of".
- A DB the proxy has never published to shows a dash, not
0. A chain whose difficulty is unknown must not render as a chain at difficulty zero; that reads as a claim, and it is the opposite one.
The displayed form is scaled (126.98 T, 111.16 k) and the exact value is
in the element's title. Below difficulty 1 it switches to significant figures
rather than decimals, for the same reason src/config.c prints this number
with %g: a forknet runs at 4.66e-10, which toFixed(2) renders as 0.00.
Light by default, dark one click away. The toggle is in both navs; it writes
data-theme onto <html> and remembers the choice in localStorage under
sp-theme.
Three rules hold this together, and each of them broke once while it was
being written — test/theme.test.js now guards all three:
partial/head.ejsre-applies the saved theme before the stylesheet paints. Every page reloads itself on a<meta refresh>timer, so a theme applied from the deferred script flashes the other one every 15 seconds.script.jsloads from the head, on every page. The button is in the shared nav, so a view that does not load the script renders a nav with a permanently hidden button.- It loads exactly once. Two copies bind two click handlers, the theme flips twice, and the button looks dead.
prefers-color-scheme is deliberately not consulted. The choice here is the
operator's, and a dashboard that ignores its own toggle because the laptop is
in dark mode is the worse surprise.
Colours are all --tokens defined twice, once per palette, in
public/style.css. Views must not hardcode a colour — use .ok, .bad,
.warn-text or .callout. A hex in a view is a value that stays
dark-theme-coloured on a light page; there is a test for that too.
Two blocks on the public pages are folded shut by default, because both are reference material that is right the first time you land and in the way on the two-hundredth refresh. Nothing is removed — both are one click open:
- "About the numbers on this page" on
/: four screens of prose on how this mode pays and how to point a rig at it. The summary names the mode, so which of the five stories is inside is still obvious from the closed state. - "Recent shares" on
/worker/:name: the 200-row raw share log. The figures that answer "is my rig working" are all in the cards above it. It renders open when a worker has no shares at all, where there is nothing to fold.
/ also merges what used to be two cards — 24h shares and lifetime totals —
into one, with lifetime as a quieter second row. Same five figures, one card's
worth of page for the comparison they exist to support.
Every page carries a strip under the header naming what this pool actually
is: the network its coinbases are built for, the mode (one of solo,
pps-classic, pplns-thunder, pplns-btc or pplns-coinbase) and fee, the
coinbase tag, the operator address the fee is paid to, and — in the
modes that pool the reward — the pool wallet the net-of-fee reward goes
to. /api/status returns the same fields under pool.
None of it is derivable from the stratum URL a miner was handed. The port looks identical whether the pool is mining mainnet or regtest, whether a block pays its finder or the pool's wallet, and whoever collects the fee.
The dashboard does not take these from its own environment. The proxy
writes them to pool_meta at startup and the dashboard reads them back —
same rule as the PPS rate, and for the same reason: a second copy of the
config is a copy that can disagree with the pool it claims to describe.
The practical consequence is that the strip reads unknown until the proxy
has restarted onto a build that publishes them. That is deliberate; a banner
that asserts the wrong network is worse than one that admits it doesn't know.
network_source says how the network was determined:
| Value | Meaning |
|---|---|
node |
getblockchaininfo answered. Authoritative. |
inferred |
It did not — the CUSF enforcer serves only getblocktemplate and submitblock — so the network was read off the operator address. Cannot distinguish testnet from signet, and says so. |
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.
The explanatory card on / branches on pool_mode, because almost nothing
in it is shared between the modes:
solo |
pps-classic |
pplns-thunder |
pplns-btc |
pplns-coinbase |
|
|---|---|---|---|---|---|
| A share that isn't a block | worth nothing | credited at the live rate | a claim on the next block found | a claim on the next block found | a claim on the next block found |
| Block reward goes to | the finder, in the coinbase | the pool's BTC wallet | the pool's BTC wallet | the pool's BTC wallet | the whole window, in the coinbase |
| A balance moves | never | as each share arrives | on maturity, over Thunder | on maturity, on L1 | never — the block is the payment |
| Stratum username | a Bitcoin address (P2WPKH / P2PKH / P2SH — not taproot) | a Thunder address | a Thunder address | a Bitcoin address | a Bitcoin address |
| Rejection if you get it wrong | invalid payout address in stratum username |
invalid thunder address |
invalid thunder address |
invalid payout address in stratum username |
invalid payout address in stratum username |
The username row is why this is not cosmetic. src/stratum.c branches at
authorize, so the card's instructions are load-bearing: a pool that tells
miners to use a Thunder address when it wants a Bitcoin one is telling them to
do the one thing that cannot work.
pplns-coinbase gets one more thing the others do not: the payout floor.
That mode does not pay a claim worth less than pplns_payout_floor_sats — it
shares it out among the miners that block could pay — never the operator, who
takes only its fee — and puts the skipped miner first in the queue for the next
block. The card says all three things, because a miner deciding whether to
point a rig here needs to know that being small costs them frequency rather
than money, and that nothing is being held on their behalf. The card states the number before anyone connects, because the
operator's log is the one place the miner it costs cannot look. It renders
only when the proxy published a floor (pool_meta.pplns_payout_floor_sats);
an older proxy stores NULL, and printing a default there would be stating some
other operator's policy for them.
A note on what this card used to do: it branched on solo / pps-classic
only, so all three PPLNS modes fell through to "this pool has not published
its mode yet" — directly beneath an identity strip that named the mode
correctly — followed by connection guidance for two modes, neither of which
was theirs. Three other places answered "not pps-classic" with the word
solo: the worker page's Owed field, the templates page's PPS rate, and
the pps_difficulty health check. If you add a sixth mode, those are the
places to check.
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.
Answers "which commit is this pool actually running?" for simplepool, the bip300301 enforcer, thunder, and bitcoind — without SSHing anywhere.
The commit is looked for in the artifact first and in a source tree only as a last resort, because a checkout's HEAD is not what the running binary was built from. Three sources, in descending order of what they prove:
provenance |
Source | What it proves |
|---|---|---|
binary |
<bin> --version prints the commit |
The answer travels inside the thing it describes. Nothing to keep in sync. |
manifest |
<bin>.build.json, pinned by sha256 |
Recorded at build time; survives the source being deleted. |
checkout |
the git tree beside the binary | What would be built now — not what is running. Never required. |
simplepool and the enforcer embed their commit, so they resolve at binary.
thunder and bitcoind print a version number and no commit; they resolve at
manifest once you have run scripts/record-build.sh (below). Without a
manifest they fall back to checkout and say so in notes.
Alongside the resolved commit / branch / repo, each component carries
the evidence it was derived from:
running— the live process, found through/proc/<pid>/exe, so it describes what is serving traffic rather than whatever now sits at the configured path.binary_replaced: truemeans the file was rebuilt and the service never restarted;process_found: falsemeans nothing is running it.manifest— the recorded build, withverifiedfalse if the binary's sha256 has moved since (rebuilt without re-recording). A stale manifest is reported, and ranked below the checkout rather than trusted.checkout— remote, branch, HEAD, and whether tracked files have been modified since (dirty).
commit_matches cross-checks whichever pairs exist, and is null — never an
optimistic true — when only one source knew a commit. all_clean plus
needs_review summarise the whole report in one field.
Run this right after building any component that doesn't embed its own commit, in the same script that does the build so the two can't drift:
scripts/record-build.sh thunder \
~/forknet-software/thunder-rust \
~/forknet-software/thunder-rust/target/release/thunder_app
It writes <binary>.build.json next to the binary. After that the checkout
can be deleted and /api/versions still answers — source is a build-time
dependency, not a runtime one. scripts/deploy-to-server.sh does this for
simplepool automatically.
Paths default to the layout this pool deploys with — all four checkouts as
siblings of the simplepool repo — and are overridden per component with
SIMPLEPOOL_REPO_DIR / SIMPLEPOOL_BIN, ENFORCER_REPO_DIR /
ENFORCER_BIN, THUNDER_REPO_DIR / THUNDER_BIN, BITCOIN_REPO_DIR /
BITCOIND_BIN, and <COMPONENT>_BUILD_MANIFEST for a manifest kept
somewhere other than beside the binary. Set both of a pair empty to drop that
component. VERSIONS_USE_CHECKOUT=0 disables the git fallback entirely, for
a deployment that ships binaries without source.
Results are cached for VERSIONS_TTL_MS (default 5 min) since they only
change on restart; add ?force=1 right after a redeploy.
Absolute paths are deliberately not in the response; repo URLs are stripped of any embedded credentials.
Reverse-proxy with optional basic auth, gzip, and a tiny cache for /api/*:
proxy_cache_path /var/cache/nginx/simplepool levels=1:2 keys_zone=simplepool:10m
max_size=100m inactive=10m use_temp_path=off;
server {
listen 80;
server_name pool.example.com;
gzip on;
gzip_types text/plain text/css application/json application/javascript;
# auth_basic "simplepool";
# auth_basic_user_file /etc/nginx/.htpasswd;
location /api/ {
proxy_pass http://127.0.0.1:8081;
proxy_cache simplepool;
proxy_cache_valid 200 5s;
add_header X-Cache-Status $upstream_cache_status;
}
location / {
proxy_pass http://127.0.0.1:8081;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}H/s ≈ sum(difficulty over window) * 2^32 / seconds_the_shares_span
This is the standard pool estimator, with one correction. The divisor is the span the shares actually cover — first share in the window to now — not the nominal width of the window. Dividing by time nothing was mined in reports a rate nobody ran at, and it goes wrong exactly when someone is most likely to be looking: a pool eleven hours old reads half its true rate against a 24 h window, and a rig ten minutes into a rented contract reads 1/144th of what it is doing. Both heal on their own as the window fills, which is why it survived so long — by the time anyone doubts the number it is right again.
The span is clamped at both ends: never longer than the nominal window, and never shorter than a minute, so a single share a few seconds old cannot divide by ~0 and report a gigahash spike. With no shares at all it falls back to the nominal window, so an idle pool reports zero rather than a clamped fraction of nothing.
It still converges slowly for workers with very few shares in-window — that is variance, not a divisor problem.