Introduction
dvb-WarpPool is a Rust Bitcoin solo mining pool, written as a modern parallel build to dvb-goPool. Focus areas:
- Performance — Rust async (Tokio), pure-Rust ZMQ + NOISE (no libzmq / OpenSSL required), reproducible release builds
- Miner compatibility — Nerdminer V2, Bitaxe (AxeOS), Avalon Q, Antminer S21/S23 Pro, Whatsminer (MicroBT), NerdNOS/Octaxe
- OS compatibility — macOS (Intel + Apple Silicon), Windows, Linux x86_64 + aarch64 (deb/rpm/AppImage), Raspberry Pi 5, Umbrel (Docker)
- Security — memory safety, TLS, JWT + 2FA-TOTP + API tokens, audit log, signed releases (cosign-keyless), reproducible builds, auto-update with on-failure rollback
- Stratum V1 + V2 — plain + TLS for V1, NOISE-NX for V2, V1↔V2 translator as a sidecar
- Admin profiles — Small / Medium / Large / Enterprise (hot-switch)
- Modern UI — SvelteKit PWA with i18n (DE/EN/ES/PT-BR/FR/IT/JA/ZH), HealthBanner + UpdateBanner via SSE events, mobile-first
- Observability — Prometheus
/metrics+ notifier sinks (ntfy / Telegram / Discord / Slack / Email) + RPC latency histogram
Current Status
The v1.0 line is released and maintained. Latest tag: v1.0.6 (2026-06-06).
The phase table below covers the development history that led up to v1.0;
post-1.0 changes are tracked in CHANGELOG.md.
| Component | Status |
|---|---|
| Phase 1 — Foundation (crates, profiles, hwdetect) | ✅ |
| Phase 2 — MVP Pool (V1 + storage + API + UI) | ✅ |
| Phase 2.1 — ZMQ hashblock subscribe | ✅ |
| Phase 2.5 — VarDiff (EMA + hysteresis + persistence) | ✅ |
| Phase 3 — Security (TLS, rate limit, JWT, 2FA, API tokens, audit) | ✅ |
| Phase 4 — UX (PWA, i18n, SSE live, charts, mobile) | ✅ |
| Phase 5 — Operations (Prometheus, mDNS, vendor APIs, backup) | ✅ |
| Phase 6 — Packaging (Docker multi-arch, native installer, Cosign, SLSA, RPi 5) | ✅ |
| Sv2 Stack (phase 7.1–7.6a) | ✅ |
| └─ Mining protocol (server + client + V1↔V2 translator) | ✅ |
| └─ Job distribution + V1 mining.notify mapping + BIP-320 | ✅ |
| └─ Template-distribution wire foundation | ✅ |
| └─ Bitcoin-node TDP wiring (phase 7.6b) | ⏸ deferred (Bitcoin Core IPC since v30) |
| Phase 8 — Polishing (mdBook, repro builds, auto-update + rollback + Cosign) | ✅ |
| Phase 9 — Setup health-checks (multi-RPC, network, probes with consent) | ✅ |
| Phase 10a — bitcoin.conf snippet generator | ✅ |
| Phase 11 — UPnP port forwarding (whitelist + consent) | ✅ |
| Phase 12 — bitcoin.conf parse-existing (smarter snippets) | ✅ |
| Phase 13 — Daemon periodic Bitcoin health-check + SSE | ✅ |
| Phase 14 — UI HealthBanner + UpdateBanner | ✅ |
| Phase 15 — Notifier fully wired + Email/Slack + admin-UI test buttons | ✅ |
| └─ 15.2b Sv2 connection notifier wiring | ✅ |
| Phase 16 — Observability (PoolMetrics + RPC latency histogram + notifier counter) | ✅ |
| Phase 17 — Payout system | ⏭ skipped (single-user solo doesn't need it; coinbase splits cover multi-user) |
| Phase 18 — mdBook operator guide + ARCHITECTURE/SECURITY refresh | ✅ |
| Phase 19 — Performance benchmarks (Criterion) | ✅ |
| Phase 20 — Live energy + health trend | ✅ |
| └─ 20.1 Time-series storage + power-tariff schema (Single/TOU/Solar) | ✅ |
| └─ 20.2 Energy + history endpoints | ✅ |
| └─ 20.3 Health anomaly detector (5 heuristics) | ✅ |
| └─ 20.3b HealthAlert notifier hook (critical → ntfy/Discord/Slack/Email/Telegram) | ✅ |
| └─ 20.4 UI EnergyCard in dashboard | ✅ |
| └─ 20.4b UI per-miner detail page with sparklines | ✅ |
| └─ 20.5 Solar HA provider (Home Assistant REST API) | ✅ |
| Phase 21 — VAPID Web Push | ✅ |
| └─ 21.1 Pure-Rust VAPID crypto + CLI gen-vapid-keys | ✅ |
| └─ 21.2 Subscribe + public-key API (4 routes) | ✅ |
| └─ 21.3 Daemon push-send loop (BlockFound/Health/Update → background push) | ✅ |
| └─ 21.4 UI subscribe flow + service worker | ✅ |
| Phase 22 — Vendor probe metrics | ✅ |
| └─ 22.1 Per-miner Prometheus metrics | ✅ |
| └─ 22.2 Discovered-miners auto-probing (env-gated) | ✅ |
| └─ 22.4 AvalonQ probe adapter | ✅ |
| └─ 22.5 Braiins OS probe adapter | ✅ |
| Phase 23 — Probe hardening | ⏭ skipped (low-value: probes are LAN-local, probe_health gauge already covers failure detection) |
Recognized miner vendors (8): Bitaxe / NerdNOS / NerdOctaxe / BitMain-Stock / Whatsminer / Avalon (A12xx-A15xx) / Avalon Q / Braiins OS / OS+.
Notifier sinks (6): ntfy / Telegram / Discord / Slack / Email-SMTP / VAPID Web Push (PWA background).
Tests (as of v1.0.6): 711 Rust tests green + 3 ignored (regtest opt-in). 347 svelte-check files / 0 errors / 14 pre-existing warnings.
Workspace: 16 library crates + 6 binaries (daemon, CLI, setup wizard, translator sidecar, miner simulator, macOS launcher). Single workspace, no external sub-repo.
What's still open:
- Phase 7.6b — TDP wiring deferred (ecosystem re-eval trigger: Bitcoin Core IPC stable or SRI production-mature)
- Phase 25 — Logos/icons for the native installer (design-asset topic)
Who builds the pool
dvb-WarpPool is a solo project by dvb-Projekt (dvbprojekt@gmx.de). Code is dual-licensed under MIT OR Apache-2.0. Contributions welcome — see the README for the disclosure policy.
Where to find what
Operator Handbook
| Chapter | Contents |
|---|---|
| Getting Started | Quick install + first connection |
| Configuration Reference | config.toml + secrets.toml + env vars |
| Setup Health-Checks | First-run wizard + daemon periodic check |
| Notifications | ntfy / Telegram / Discord / Slack / Email setup |
| Observability | Prometheus metrics reference + Grafana + alerts |
| Troubleshooting | Symptom → diagnosis → fix |
| Packaging & Deployment | Docker, .deb/.rpm/.AppImage/.dmg/.msi, RPi 5, Umbrel |
| Security | Threat model, auth stack, key material, audit log |
| Reproducible Builds | lto=fat + SOURCE_DATE_EPOCH + verify script |
| Auto-Update | Update loop + Cosign + rollback |
Architecture
| Chapter | Contents |
|---|---|
| System Architecture | Crate layout, daemon tasks, storage schema, Sv2 stack |
| UI Design | Warp-drive concept + implementation status |
| Testing | Unit / integration / sim / regtest |
| Performance Benchmarks | Criterion suites + baseline numbers |
Sv2 Stack
| Chapter | Contents |
|---|---|
| Phase History | Sv2 chronologically (phase 7.1 → 7.6a) |
| Roadmap | All phases + what's still open |
Quick Links
- Source: github.com/dvb-projekt/dvb-WarpPool
- Release pipeline + SBOM:
.github/workflows/release.yml - Bench workflow:
.github/workflows/benches.yml - Repro-verify:
scripts/verify-reproducible.sh - Regtest setup:
scripts/regtest-up.sh+regtest-down.sh - Sister project (Go implementation): dvb-goPool
Getting Started
Three paths to a running pool:
- Docker — multi-arch image, runs on Umbrel-Box, NAS, or any Linux/macOS host
- Native Binary —
.deb/.rpm/.dmg/.msi/.AppImagefor bare-metal hosts and Raspberry Pi 5 - From Source — for development or verification
Prerequisite in every case: a running Bitcoin Core (≥27.0 recommended) with
the getblocktemplate RPC and ZMQ block-notify enabled.
Docker
docker run -d \
--name warppool \
--restart unless-stopped \
-p 18334:18334 \
-p 3333:3333 \
-v ~/.warppool:/var/lib/warppool \
ghcr.io/dvb/dvb-warppool:latest
First call of the setup wizard: http://localhost:8331 (auto-detects
hardware, suggests a profile, tests the Bitcoin RPC).
Native Binary
Raspberry Pi 5 (aarch64)
# arm64 .deb
wget https://github.com/dvb-projekt/dvb-WarpPool/releases/latest/download/dvb-warppool_arm64.deb
sudo dpkg -i dvb-warppool_arm64.deb
sudo systemctl enable --now dvb-warppool
Storage recommendation: NVMe-HAT > USB-3 SSD > industrial SD. The SQLite WAL
plus shares_raw eviction is hard on SD cards under sustained pool load.
macOS / Linux x86_64
Tarball/pkg from the GitHub releases. The macOS .dmg is unsigned
(notarization is queued for phase 8b); run
xattr -dr com.apple.quarantine /Applications/dvb-WarpPool.app after
mounting.
From Source
git clone https://github.com/dvb-projekt/dvb-WarpPool
cd dvb-WarpPool
cargo build --release --workspace
./target/release/dvb-warppool-setup # First-Run Wizard auf :8333
The cargo target directory lands in ~/.cache/dvb-WarpPool/target (see
.cargo/config.toml) so that iCloud sync doesn't trash the build directory.
Stratum V1 vs V2
The pool starts both listeners in parallel by default:
- V1 plain (
:3333) — all existing miner firmwares - V1 TLS (
:3334) — ifstratum_tls_listen+ cert/key are set in config.toml - V2 NOISE (
:34254) — ifsv2_listen+sv2_static_priv_key_hexare set in secrets.toml
For V1-miner sidecars (e.g. when the pool should expose only Sv2 externally):
the bundled dvb-warppool-translator binary. See
Phase History → 7.4.
First Block
- Configure your miner with
stratum+tcp://<pool-host>:3333as the pool - Use your BTC receive address as the worker name (e.g.
bc1q...) - Open the pool UI at
http://<pool-host>:18334 - The pool hashrate chart shows shares live; the
/blockstab shows found blocks
When a block lands → BlockFoundEvent runs immediately through
bitcoin-rpc::submit_block and the notifier sends ntfy / Telegram /
Discord / Slack / Email / browser push.
Next Steps
- Configuration Reference — all config.toml fields
- Notifications — set up notifier sinks
- Observability — Prometheus endpoint + Grafana
- Troubleshooting — when something isn't working
Solo + Friends — Full TLS Setup Guide
This guide turns a self-hosted dvb-WarpPool into a friends-only pool reachable from the internet via TLS with a real Let's Encrypt certificate. The OSS distribution covers this tier (see scaling.md) — no commercial extensions needed.
Target audience: you run WarpPool on a Mac or Linux at home with a Fritz!Box (or similar router with DDNS support). You want to invite 5-10 trusted friends to point their miners at your pool over the internet.
Time required: about 15-20 minutes.
Contents
- Why DuckDNS + Let's Encrypt
- Phase 1 — Create a DuckDNS account
- Phase 2 — Configure router DDNS
- Phase 3 — Open port 3334 on the router
- Phase 4 — Run the setup script
- Phase 5 — Restart the daemon
- Phase 6 — Verify the certificate
- Phase 7 — Move your own miners over
- Phase 8 — Invite your friends
- Sleep prevention on macOS
- Maintenance
- Troubleshooting
- Security notes for this tier
- Cheat sheet
Why DuckDNS + Let's Encrypt
Bitaxe firmware in "Bundled CA" mode (the secure default) validates the server certificate against the Mozilla CA root bundle. A self-signed certificate, no matter how well crafted, is rejected. We need a real cert.
Real certs come free from Let's Encrypt — but only for a DNS name, not a
bare IP. So we also need a "name for your Mac on the internet". DuckDNS
provides exactly that: a free <yourname>.duckdns.org subdomain that always
resolves to your current home IP, even when your ISP rotates it.
What you'll have at the end:
<sub>.duckdns.orgresolves to your router's public IP, automatically- The router forwards port 3334 to your Mac
- Your Mac serves a valid Let's Encrypt cert for
<sub>.duckdns.org - Bitaxes trust the cert (because Let's Encrypt is in the bundle)
- External miners can connect with full TLS verification
Phase 1 — Create a DuckDNS account
Time: 2 minutes.
- Open https://www.duckdns.org/ in your browser.
- Sign in via GitHub, Twitter, Reddit, or Google in the top right.
- You land on your dashboard. Note:
- Token — a 32-character hex string at the top right
- The empty subdomain field with an "add domain" button next to it
- Type a subdomain name (e.g.
warppool-oliverormypool-mining— anything still free) and click "add domain". - The subdomain appears in your list. Write down:
- Subdomain (just the
warppool-oliverpart, no.duckdns.org) - Token (top right, 32 hex characters)
- Subdomain (just the
You'll need both in Phase 2 and Phase 4.
Tip: Do not post the token in Slack/Discord/etc. Anyone with it can point your subdomain at a different IP.
Phase 2 — Configure router DDNS
Time: 3 minutes.
DDNS = Dynamic DNS. Your router calls DuckDNS every few minutes to say "the IP
for warppool-oliver is now X". That keeps your subdomain pointed at home
even when your provider rotates the IP.
The exact menu paths differ per router. Below is the Fritz!Box workflow (very common in Germany). For other routers, look for "DynDNS" / "Dynamic DNS" in the WAN / Internet section.
Fritz!Box
-
Browser to http://fritz.box/ or http://192.168.178.1/.
-
Log in with your Fritz!Box password.
-
Left menu: Internet → Permit Access (or Freigaben).
-
Tab DynDNS.
-
Enable "Use DynDNS".
-
DynDNS provider: "Custom" (or "Benutzerdefiniert").
-
Update URL: paste exactly this line (replace
<SUBDOMAIN>and<TOKEN>with your values):https://www.duckdns.org/update?domains=<SUBDOMAIN>&token=<TOKEN>&ip=<ipaddr>The
<ipaddr>at the end is a placeholder that the Fritz!Box replaces with your current IP — leave it literally as<ipaddr>. -
Domain name:
<SUBDOMAIN>.duckdns.org(e.g.warppool-oliver.duckdns.org) -
User: anything (e.g.
none) — DuckDNS only checks the token. -
Password: anything (e.g.
none). -
Click Apply / Übernehmen.
Verification
In a terminal on your Mac:
dig +short warppool-oliver.duckdns.org @8.8.8.8
Should return your public IP (compare with https://www.whatismyip.com/). If empty: wait 30 seconds and retry — the router needs to push its first update.
Phase 3 — Open port 3334 on the router
Time: 3 minutes.
External miners connect to <sub>.duckdns.org:3334 → the request hits your
router → the router needs to know which device to forward it to (your Mac).
Fritz!Box
- Internet → Permit Access → Port Forwarding (Portfreigaben).
- Click "Add Device for Sharing" (or edit an existing entry if your Mac is already there).
- Select your Mac (should appear as
MacBook-Pro/mac-minietc. with IP192.168.178.10). - Click "New Sharing" → "Port Sharing".
- Application: "Other Application".
- Label:
WarpPool TLS Stratum. - Protocol: TCP.
- Port to device:
3334. - Public port:
3334. - Check "Enable sharing".
- OK → Apply.
Verification
On an external device (e.g. your phone with WiFi off, using mobile data):
nc -zv warppool-oliver.duckdns.org 3334
Should print Connection to ... port 3334 [tcp/*] succeeded!.
If it works from your Mac but not from outside, you have a NAT loopback issue on the router. Run the external test from a truly external device.
Phase 4 — Run the setup script
Time: 2 minutes.
Now we fetch the Let's Encrypt cert.
-
Open a terminal.
-
Switch to the WarpPool folder:
cd ~/code/dvb-WarpPool -
Start the script:
./scripts/setup-tls-public.sh -
The script will prompt:
? DuckDNS Subdomain (without .duckdns.org):→
warppool-oliver? DuckDNS Token (32-hex from your account settings):→ paste the token from Phase 1
-
Then it runs:
- Installs
acme.sh(the Let's Encrypt CLI client) if missing — ~30s - Fetches the cert via DNS-01 challenge:
- Sets a TXT record at DuckDNS
- Waits for DNS propagation (~30-60s)
- Let's Encrypt verifies the TXT record → confirms domain ownership
- Cert is issued and saved locally
- Installs cert + key into
~/Library/Application Support/dvb-WarpPool/tls/{cert,key}.pem - The old self-signed files are backed up as
.bak-<unix-ts> - Registers itself in cron for 60-day auto-renewal
- Installs
-
At the end you'll see:
✓ TLS setup for warppool-oliver.duckdns.org complete Next steps: 1) Restart the WarpPool daemon ... 2) On the Bitaxe (Pool Configuration): ... 3) Verify the Fritz!Box settings: ... 4) Test (from outside or on LAN): ...
Phase 5 — Restart the daemon
Time: 30 seconds.
The daemon only reads the cert at startup, so restart it once:
pkill -f dvb-warppool-daemon
sleep 2
open /Applications/dvb-WarpPool.app
After 3-5 seconds the daemon is back. Verify:
tail -20 ~/Library/Application\ Support/dvb-WarpPool/daemon.log | grep -i "tls"
Should show:
spawning TLS stratum listener bind=0.0.0.0:3334 cert=/Users/.../tls/cert.pem
stratum-v1 TLS server listening bind=0.0.0.0:3334 max_conn=64
Phase 6 — Verify the certificate
Time: 1 minute.
Check that the cert appears valid from outside.
echo | openssl s_client -connect warppool-oliver.duckdns.org:3334 \
-servername warppool-oliver.duckdns.org 2>&1 \
| grep -E "Verify return code|subject=|issuer=|Server certificate"
Good (what you want):
subject=CN=warppool-oliver.duckdns.org
issuer=C=US, O=Let's Encrypt, CN=R10
Verify return code: 0 (ok)
Bad (something didn't take):
subject=CN=dvb-WarpPool ← still the self-signed cert
Verify return code: 18 (self-signed certificate)
If you see the bad result:
- Did you actually restart the daemon?
pgrep -f dvb-warppool-daemonshould show a different PID than before the restart. - Does config.toml point at the new cert?
grep tls_cert_path ~/Library/Application\ Support/dvb-WarpPool/config.toml
Verify return code: 0 means the cert is valid — Bitaxe firmware will accept
it.
Phase 7 — Move your own miners over
Time: 2 minutes per miner.
Example for a BitForge Nano (web UI at http://192.168.178.44/):
-
Browser to
http://192.168.178.44/. -
Pool Configuration → Main Pool tab.
-
Set fields:
Field Value Stratum Host warppool-oliver.duckdns.orgStratum Port 3334Stratum User (unchanged: bc1q...YOUR_ADDRESS.BitForgeNano)Stratum Password (unchanged, e.g. x)TLS Enabled (Bundled CA) ← finally works -
Click Save.
-
Bitaxe says "must restart this device after saving" → click Restart.
-
Bitaxe reboots. After ~20 seconds it's back.
-
The Bitaxe UI should show an active pool connection again under "Status", with shares ticking.
-
In the WarpPool admin at
http://localhost:18334/: the worker table should still show the Bitaxe, with a 🔒 lock icon next to its name indicating the TLS connection. The "Active Miners" tile shows a "N TLS" sub-line counting your TLS connections.
→ Successfully migrated to TLS.
Repeat for any other miner you own (NerdOctaxe, NerdQaxe++, Avalon Q, etc.). ~2 minutes per miner.
Phase 8 — Invite your friends
Send them:
Hey, you can mine on my WarpPool:
- Pool:
warppool-oliver.duckdns.org- Port:
3334- TLS: yes, "Enabled (Bundled CA)" / "Strict TLS"
- Username:
<YOUR_OWN_bech32_address>.<any_worker_name>Solo pool — 100% of the reward goes to whoever finds the block. If the pool is down, fall back to your backup.
Important: every friend uses their own wallet address as the stratum user. WarpPool is solo mining — the reward goes directly to the address the miner authorizes with.
Sleep prevention on macOS
Once your friends are connecting from outside, the pool needs to stay reachable
24/7. By default, macOS suspends the system after 3 hours of idle time
(pmset sleep 180). While suspended:
- Bitcoin Core is frozen —
getblocktemplateandsubmitblocktime out - Stratum TCP connections drop (Bitaxes log
Connection reset by peer) - The hashrate chart shows repeated drops to 0 throughout the night, with brief recovery whenever the Mac briefly wakes for Power Nap or notifications
This is handled automatically. Since v1.0.6, the Mac launcher wraps the
daemon spawn with caffeinate -i -w <pid>:
-i= prevent system idle sleep (CPU + network + Bitcoin Core stay up)-w PID= caffeinate exits automatically when the daemon dies → no zombie processes, no manual cleanup- Display sleep keeps working — you can let the screen go dark, the screensaver runs, etc.
Verify it's active:
ps -A -o pid,ppid,command | grep "caffeinate -i -w $(pgrep -f dvb-warppool-daemon | head -1)"
You should see one caffeinate -i -w <daemon-pid> line.
The admin profile page (/admin/profile) shows a green info card "✓ macOS
sleep mode disabled" when this is active. If the card is missing, the
launcher couldn't spawn caffeinate (very unlikely — it's a system binary on
every macOS install).
What this does NOT handle
- Explicit sleep (Apple menu → Sleep, or closing a MacBook lid). The user asked for sleep, so the pool sleeps too. Open the lid / wiggle the mouse and the pool comes back within seconds (daemon stays running across short suspensions; longer suspensions trigger Bitaxe reconnects).
- Hard shutdown / power loss. Use a UPS if you're worried about this.
- Force-quit of the launcher. If you kill the launcher process directly
(
Activity Monitor → Force Quit), the daemon survives but caffeinate's parent process is gone, so caffeinate may exit. Usepkill -f dvb-warppool-daemonor re-open the.appto do a clean restart.
Power consumption
caffeinate -i is only a sleep-prevention flag — it doesn't pin the CPU
or keep the disk spinning. Your Mac mini / MacBook idles at whatever
wattage it normally would when the screen is off. The actual pool workload
(Stratum I/O + occasional Bitcoin Core RPC) is in the single-digit-watt
range on Apple Silicon. No noticeable impact on a Mac mini's electricity
bill.
Disabling sleep prevention
If you want the Mac to be allowed to sleep (e.g. you don't care about pool uptime overnight), kill the caffeinate process by hand:
pkill caffeinate
The daemon keeps running until macOS suspends it.
There's currently no config flag to disable sleep prevention — it's the correct behaviour for nearly every pool operator, and disabling it makes the pool unreliable. Open an issue if you have a real use case for opt-out.
Maintenance
Cert renewal
acme.sh does it automatically every 60 days (Let's Encrypt certs are valid
for 90 days). You don't have to do anything except restart the daemon
after a renewal happens, so it loads the new cert.
A pragmatic cron line that handles this for you:
crontab -e
Add:
0 4 * * * cd $HOME && ~/.acme.sh/acme.sh --cron > /dev/null 2>&1 && pkill -f dvb-warppool-daemon ; sleep 3 ; open /Applications/dvb-WarpPool.app
Runs daily at 4am: checks renewal status, restarts the daemon if anything changed. If there's nothing to renew, the restart causes no harm — the daemon is back in ~5s and Bitaxes reconnect automatically.
Force a renewal manually
To test:
~/.acme.sh/acme.sh --renew --domain warppool-oliver.duckdns.org --force
The --force bypasses the 60-day limit.
IP changes
As long as the router stays in place, everything keeps working — it updates DuckDNS continuously. Only when you swap the router (new ISP, new hardware) do you need to re-do the DDNS config (Phase 2).
Stopping the pool
Nothing special — close the .app or pkill -f dvb-warppool-daemon. Friends'
Bitaxes automatically fail over to backup, then come back when your pool is
back up.
Troubleshooting
Bitaxe says "TLS connection failed"
-
Router port forward gone?
nc -zv warppool-oliver.duckdns.org 3334from outside — must say "succeeded". -
DuckDNS not resolving, or pointing at the wrong IP?
dig +short warppool-oliver.duckdns.org @8.8.8.8— must show your current public IP (compare withcurl ifconfig.me). -
Cert expired? (shouldn't happen because of auto-renewal, but just in case):
echo | openssl s_client -connect warppool-oliver.duckdns.org:3334 \ -servername warppool-oliver.duckdns.org 2>&1 \ | openssl x509 -noout -datesnotAfter=...should be at least 30 days in the future. -
Bitaxe firmware cache: some Bitaxe firmware versions cache a failed cert. Power cycle the Bitaxe (unplug for 10s, replug). The reset button is not enough.
Script aborted somewhere
Running it again is OK — the script is idempotent. If e.g. acme.sh is
already installed, it's skipped.
To start completely fresh:
~/.acme.sh/acme.sh --remove --domain warppool-oliver.duckdns.org
rm -rf ~/.acme.sh/warppool-oliver.duckdns.org/
Then re-run the script.
Cert doesn't load after restart
Daemon log still shows the old cert?
lsof -p $(pgrep -f dvb-warppool-daemon) | grep cert.pem
Should point at the new cert. If not: pkill -KILL -f dvb-warppool-daemon
(instead of regular pkill) and re-open the .app.
"Rate Limit Exceeded" from Let's Encrypt
You can only issue 5 certs per week per domain. If you've been testing and issued multiple: wait, or use the staging server:
~/.acme.sh/acme.sh --issue --dns dns_duckdns --domain warppool-oliver.duckdns.org \
--server https://acme-staging-v02.api.letsencrypt.org/directory
Staging certs are NOT in the Mozilla CA bundle — they won't work for Bitaxe, but they're fine for verifying that the infrastructure is set up correctly.
Fully rolling back to self-signed
If you want to go back to LAN-only operation:
~/.acme.sh/acme.sh --remove --domain warppool-oliver.duckdns.org
cd ~/Library/Application\ Support/dvb-WarpPool/tls/
# Restore the .bak-* files the script created:
ls -la *.bak-*
# Copy the most recent cert.pem.bak-XXX and key.pem.bak-XXX back:
cp cert.pem.bak-<LATEST> cert.pem
cp key.pem.bak-<LATEST> key.pem
pkill -f dvb-warppool-daemon ; sleep 2 ; open /Applications/dvb-WarpPool.app
Then put the Bitaxe back on port 3333 without TLS (or TLS "Insecure" mode).
Security notes for this tier
This setup gives you:
- Encrypted Stratum traffic (TLS 1.3 with AES-256-GCM)
- Server identity verified via Let's Encrypt → no man-in-the-middle attack can impersonate your pool
- Friends who type the wrong hostname get a cert mismatch warning → fail-safe rather than silently failing into a stranger's pool
It does not give you:
- DDoS protection (you have a 64-connection cap, no per-IP limiting)
- Authentication beyond bech32 address format (anyone with your hostname can point a miner at you and submit shares — to their own address)
- Worker IP privacy (visible in your admin panel + logs)
For 5-10 friends this is fine. If your DuckDNS hostname leaks publicly and random people start mining to your pool, your only recourse is changing the hostname. For real abuse resistance you need tier 3 — see scaling.md.
Cheat sheet
# Initial setup (one time)
cd ~/code/dvb-WarpPool && ./scripts/setup-tls-public.sh
# Daemon restart (after each cert renewal)
pkill -f dvb-warppool-daemon ; sleep 2 ; open /Applications/dvb-WarpPool.app
# Force a renewal
~/.acme.sh/acme.sh --renew --domain warppool-oliver.duckdns.org --force
# Check cert status
echo | openssl s_client -connect warppool-oliver.duckdns.org:3334 \
-servername warppool-oliver.duckdns.org 2>&1 \
| openssl x509 -noout -dates -subject -issuer
# DNS + port reachability from outside
dig +short warppool-oliver.duckdns.org @8.8.8.8
nc -zv warppool-oliver.duckdns.org 3334
Good luck!
Scaling dvb-WarpPool
dvb-WarpPool is an open-source solo mining pool with operator UX. The codebase covers everything you need to mine to your own wallet with your own hardware, including for a small group of trusted friends. Beyond that, public pool operations introduce a different class of requirements (DDoS, reward sharing, multi-admin, compliance) that the OSS distribution does not address.
This document defines the four deployment tiers, what the OSS stack covers, and where commercial extensions begin.
Tier Matrix
| Tier | Use case | OSS sufficient? | Profile mapping |
|---|---|---|---|
| Solo | One operator, own miners on LAN | Yes | Klein |
| Solo + Friends | 5-10 miners, trust-based, LAN or Internet | Yes | Mittel |
| Community Pool | 50-100 miners, Discord-vetted | Partially — needs extensions | Gross |
| Public Service | Publicly advertised, 500+ miners | No — requires commercial stack | Enterprise |
What the OSS stack ships
Applies to all tiers:
- Stratum-V1 plain + TLS, Stratum-V2 NOISE
- Bitcoin Core solo mining (full reward to the block finder)
- Class-aware VarDiff (Bitaxe / Antminer / Avalon Q / Avalon ASIC / NerdMiner / NerdNOS / Whatsminer)
- Admin UI with Argon2 passwords, optional 2FA, signed auto-update channel
- Live miner telemetry probes (AxeOS, MM-Summary for Avalon, WantClue NerdNOS, NerdMiner V2)
- Energy tracking (live / 24h / 7d / lifetime with reset)
- Hashrate chart with best-share overlay + 24h aggregation + Week/Month/Year view from daily aggregates
- Notifier hooks (email, Web Push) for block-found events
- Distribution: macOS .dmg, Linux .deb / .rpm / AppImage, Windows .msi, Docker multi-arch, Umbrel community store
For Solo and Solo + Friends that stack is production-ready. The only external steps for the friends tier are:
- A DNS hostname (e.g. via DuckDNS) pointing at your public IP
- A Let's Encrypt certificate (the Bitaxe "Bundled CA" mode requires a CA that's in the Mozilla root bundle)
- Router port-forward for the TLS Stratum port (default
:3334)
What the OSS stack does NOT ship
These are deliberately scoped out — they're how a pool transitions from a personal tool into a service.
Tier 3 — Community Pool gaps
| Gap | Why it matters at this scale |
|---|---|
| Reverse proxy with rate-limit + access-log | OSS binds TLS directly in the daemon; Caddy/Nginx in front enables per-IP limits, easier cert rotation, and a single audit log |
| Per-IP connection limit | Global connection-cap=64 is trivially exhausted by one attacker holding 64 idle TCP connections |
| Worker IP / topology disclosure gating | /api/workers and daemon logs expose peer IPs unauthenticated — fine for a personal pool, a privacy issue if anyone untrusted can read the dashboard |
| Abuse-pattern detection | A worker that only submits invalid shares thrashes VarDiff and burns CPU on validation; needs auto-ban on > N% rejected |
| Automated DB backup | Admin restore card exists for config, but warppool.db snapshotting is manual |
| Stratum whitelist / registration | OSS accepts any bech32 address — operators of a real community pool usually want to register members |
Tier 4 — Public Service gaps (in addition to Tier 3)
| Gap | Why it matters at this scale |
|---|---|
| DDoS mitigation | Cloudflare TCP pass-through or dedicated providers; connection-cap alone is not protection |
| PPLNS / FPPS payout engine | OSS is pure solo (100% of the reward to the finder); a publicly advertised pool without reward sharing will not attract miners |
| Multi-admin + role-based access | Single admin user; production needs read-only roles and per-user audit |
| T&C / Privacy templates | EU operators need GDPR-compliant worker-IP processing notice; liability if a found block is lost |
| Public stats page | Anonymous view of hashrate / active miners / block history for prospective miners |
| Block-found webhooks | Discord / Telegram integrations are not in OSS |
| Block explorer linking | Every block in the UI should link to mempool.space |
| Monitoring stack | /metrics is exposed (Prometheus format), but a real scrape config + alerting beyond the local notifier + Grafana dashboards are operator-supplied |
| Sentry / error tracking | Daemon panics go to the tracing log only |
| SLA / maintenance contract | 24/7 on-call is a hard requirement for an advertised public pool |
Tier 4 — Compliance / Legal
| Area | Specialist |
|---|---|
| Pool T&C template | Lawyer |
| Privacy policy (GDPR) | Lawyer |
| AML / KYC if pool fees apply | Tax advisor + possibly BaFin / FCA / equivalent |
| Income taxation of pool fees | Tax advisor |
How to request Tier 3 or Tier 4 extensions
Open a GitHub issue in this repo:
- Tier 3 (Community Pool): label
community-pool - Tier 4 (Public Service): label
enterprise-inquiry
Please include in the issue body:
- Use case (private / community / commercial)
- Expected number of miners
- Hosting setup (own server / cloud / Umbrel / Mac / etc.)
- Timeline + budget range
- Specific items from the gap list above that you need addressed
Response time: typically 2-5 business days. You will receive a fixed-price proposal (Tier 4) or an hour estimate (Tier 3).
Why this split exists
The OSS distribution is what one person + a small trusted group can run safely themselves. Anything beyond that requires ongoing operations work — DDoS response, payout reconciliation, compliance updates, user support — that does not belong in a publicly forkable codebase. Splitting it this way keeps the core honest about its scope, and lets the project sustain itself if anyone wants to run it at scale.
Configuration Reference
dvb-WarpPool has two configuration files plus a set of env vars for optional subsystems:
config.toml— all non-sensitive settings (pool behavior, Stratum, VarDiff, notifier schema)secrets.toml— auth hashes, RPC credentials, Sv2 keys; always chmod 600- env vars — opt-in subsystems (auto-update, health-check interval, notifier secrets, debounce tuning)
Defaults are defined in crates/config/src/lib.rs; this page is the
operator's view.
config.toml
[branding]
[branding]
status_brand_name = "dvb-WarpPool"
server_location = "self-hosted"
fiat_currency = "EUR"
pool_donation_address = ""
Controls the strings shown in the UI and in the Prometheus build_info
metric. No effect on functionality.
[logging]
[logging]
debug = false
net_debug = false
json = true # JSON-formatted for Loki/Promtail; set false for readable stdout
Runtime override via env var: RUST_LOG=warppool=debug takes precedence
over the config setting.
[mining]
[mining]
payout_address = "bc1q..." # REQUIRED — block-reward recipient
pool_fee_percent = 0.0 # Default = no pool fee (solo)
pool_fee_address = "" # Required when fee_percent > 0
pooltag_prefix = "dvbprojekt-WarpPool" # 19 chars max, written to coinbase scriptSig
operator_donation_address = ""
operator_donation_percent = 0.0
For multi-user-solo (hosting-service setup): set pool_fee_percent = 1.0
and pool_fee_address. The sum of fee + donation must not exceed 99%.
[node]
[node]
rpc_url = "http://127.0.0.1:8332"
zmq_hashblock_addr = "tcp://127.0.0.1:28332"
zmq_rawblock_addr = "tcp://127.0.0.1:28333"
rpc_cookie_path = "/home/bitcoin/.bitcoin/.cookie" # optional, alternative to user/pass in secrets.toml
The cookie takes precedence over user/pass in secrets.toml. Leaving the
ZMQ fields empty enables poll-only mode (slower but functional).
[server]
[server]
pool_listen = ":3333"
status_listen = ":18334"
status_tls_listen = ":18443" # optional
status_public_url = "https://pool.example.com" # for push notifications
trust_proxy_headers = false # only set true when the daemon runs behind Caddy/nginx
status_public_url is required by the UI to build absolute URLs for web
push and email links. Leave empty when not running behind a reverse
proxy.
[stratum]
[stratum]
stratum_tls_listen = ":3443" # optional, V1 TLS
stratum_password = "" # empty = address-format auth only
safe_mode = true # Conservative validator
tls_cert_path = "/etc/dvb-warppool/cert.pem"
tls_key_path = "/etc/dvb-warppool/key.pem"
sv2_listen = ":3334" # optional, Stratum V2
sv2_max_connections = 1024
Sv2 requires secrets.sv2_static_priv_key_hex. If it is missing, Sv2 is
disabled with a warning (the daemon still starts).
[tuning]
[tuning]
max_saved_workers_per_user = 64
max_saved_workers_pool_total = 1024
Storage cap. Above these thresholds, old/idle workers are evicted (FIFO
by last_seen_at).
[profile]
[profile]
kind = "klein" # klein | mittel | gross | enterprise; omit = auto
expected_miner_count = 5 # for auto: estimate used by auto-detection
The profile affects defaults and resource limits. Hot-switchable via
POST /api/admin/profile — the config value is only the fallback at first
start.
[vardiff]
[vardiff]
enabled = true
target_seconds_per_share = 30.0
window = 16
min_diff = 1.0
max_diff = 65536.0
retarget_after_n_shares = 8
hysteresis = 0.30 # 30% — a new diff that deviates > 30% from the old one triggers a retarget
max_step = 4.0 # max 4× per retarget
initial_diff = 1.0
EMA-based, with persisted snapshots in the vardiff_state table. Tests:
crates/stratum-v1/src/vardiff.rs.
[ratelimit]
[ratelimit]
enabled = true
connects_per_sec = 5.0 # per peer IP
connect_burst = 20
auths_per_sec = 1.0
auth_burst = 10
idle_evict_secs = 300 # after 5 min without traffic → bucket state is evicted
Per-peer-IP token bucket. Triggered limits return mining.authorize with
result: false (no disconnect — the miner should not get stuck in a
reconnect loop).
[notifier]
See Notifications for detailed sink setup guides.
[notifier]
webpush_subscriptions_path = "/var/lib/dvb-warppool/webpush.json"
[notifier.ntfy]
topic_url = "https://ntfy.sh/my-pool-topic-secret"
on_block_found = true
on_miner_disconnect = false
on_rpc_down = true
[notifier.telegram]
bot_token_env = "TELEGRAM_BOT_TOKEN"
chat_id = "123456789"
on_block_found = true
[notifier.discord]
webhook_url_env = "DISCORD_WEBHOOK_URL"
on_block_found = true
[notifier.slack]
webhook_url_env = "SLACK_WEBHOOK_URL"
on_block_found = true
on_miner_disconnect = false
on_rpc_down = true
[notifier.email]
smtp_url = "smtps://pool@mail.example.com:465"
from = "pool@example.com"
to = ["operator@example.com"]
password_env = "POOL_SMTP_PASSWORD"
on_block_found = true
on_rpc_down = true
All sinks are optional. The daemon starts fine without a [notifier]
section at all. Sinks whose env vars are missing are skipped with a
notifier sink skipped warning.
secrets.toml (chmod 600)
rpc_user = "warppool" # optional, alternative to cookie
rpc_pass = "..." # optional, ditto
admin_username = "admin" # default "admin"
admin_password_hash = "$argon2id$..." # via `dvb-warppool-cli hash-password`
jwt_secret = "..." # min 32 bytes recommended; empty → admin auth disabled
sv2_static_priv_key_hex = "..." # via `dvb-warppool-cli gen-sv2-key`
An empty admin_password_hash or empty jwt_secret makes every
/api/admin/* endpoint return 503 "auth disabled". Useful for a
read-only setup without an admin UI.
Phase 21: VAPID Web Push Secrets
vapid_public_key = "B..." # via `dvb-warppool-cli gen-vapid-keys`
vapid_private_key = "..." # same command
vapid_contact = "mailto:operator@example.org" # optional, default mailto:operator@localhost
The public key is served via /api/push/vapid-public-key (public,
no-auth — the UI needs it for PushManager.subscribe). The private key
stays daemon-side only. If either value is empty, web push is disabled —
the daemon log reports "web-push disabled (no vapid keys)".
Environment Variables
Bitcoin Health Check
| Var | Default | Description |
|---|---|---|
WARPPOOL_HEALTH_CHECK_INTERVAL_SECONDS | 60 | Interval of the daemon's periodic Bitcoin health check. 0 = off. |
When the check is enabled, the daemon emits HealthSnapshot SSE events
and fires RpcDown/RpcRecovered notifications on transitions.
Auto-Update
| Var | Default | Description |
|---|---|---|
WARPPOOL_AUTOUPDATE_REPO | (unset) | e.g. dvb-projekt/dvb-WarpPool. When set, /api/admin/update is active. |
WARPPOOL_AUTOUPDATE_INTERVAL_HOURS | 24 | How often to poll GitHub for new releases. 0 = off. |
WARPPOOL_COSIGN_BIN | (unset) | Path to the cosign binary for verify-blob. Required when cosign_verify=true in the update request. |
Stratum / Notifier Tuning
| Var | Default | Description |
|---|---|---|
WARPPOOL_DISCONNECT_DEBOUNCE_SECS | 30 | Per-worker debounce for MinerDisconnect notifications. |
Solar/PV Provider (Phase 20.5)
The electricity tariff switches to excess_rate_eur_kwh (typically 0.0)
when the PV array produces more than house + pool consume together.
Currently implemented: Home Assistant via its REST API.
[mining.electricity.solar]
enabled = true
kind = "home_assistant"
url_env = "WARPPOOL_HA_URL" # e.g. http://homeassistant.local:8123
token_env = "WARPPOOL_HA_TOKEN" # Long-Lived Access Token
pv_entity_id = "sensor.pv_power_total"
consumption_entity_id = "sensor.grid_load_total" # optional
poll_interval_secs = 60 # 60s is enough to track PV trends
surplus_buffer_w = 200 # safety buffer
excess_rate_eur_kwh = 0.0 # self-generated power = free
stale_after_secs = 300 # fall back after 5 min without a snapshot
Create the token in HA: Profile → Long-Lived Access Tokens → Create Token. Entity IDs are listed under Developer Tools → States.
If consumption_entity_id is omitted, the pool compares the PV value
directly against its own consumption (not the whole-house total).
/api/energy reports current_rate_source = "solar-excess" and a
solar object containing pv_w / consumption_w / excess_w / age_seconds.
Vendor Probes (Phase 22)
| Var | Default | Description |
|---|---|---|
WARPPOOL_AUTO_PROBE_DISCOVERED | (off) | 1/true/yes: in addition to DB miners, devices found via mDNS are polled every 30s. Telemetry values land in an in-memory cache (no DB write) and are exposed in /metrics with label="discovered". |
Health Anomaly Check (Phase 20.3b)
| Var | Default | Description |
|---|---|---|
WARPPOOL_ANOMALY_CHECK_INTERVAL_SECS | 300 | Interval of the periodic anomaly detection. 0 = off. |
WARPPOOL_ANOMALY_DEBOUNCE_SECS | 1800 | Per-(miner, alert_kind) debounce. While a symptom persists, the notifier does not fire on every tick — only again after this interval. |
Critical alerts (FanStuck, StaleData) are sent to every sink with
on_health_alert = true (default). Warnings (ThermalThrottling,
VoltageDrop, HashrateDrop) are UI-only via /api/miners/:id/alerts.
Notifier Secrets
The variable names are FREELY CHOSEN — you set the name in config.toml,
and the daemon reads that env var.
Typical names:
TELEGRAM_BOT_TOKEN→notifier.telegram.bot_token_envDISCORD_WEBHOOK_URL→notifier.discord.webhook_url_envSLACK_WEBHOOK_URL→notifier.slack.webhook_url_envPOOL_SMTP_PASSWORD→notifier.email.password_env
Test/Debug
| Var | Default | Description |
|---|---|---|
RUST_LOG | info | Standard tracing-subscriber filter. Use warppool=debug,info for selective debug. |
WARPPOOL_E2E_REGTEST_* | — | Set by scripts/regtest-up.sh. See Testing. |
CLI Override
dvb-warppool-daemon --help lists the CLI flags that can override
individual config values. Common ones:
dvb-warppool-daemon \
--config /etc/dvb-warppool/config.toml \
--secrets /etc/dvb-warppool/secrets.toml \
--db-url sqlite:///var/lib/dvb-warppool/pool.db \
--ui-dir /usr/share/dvb-warppool/ui \
--no-zmq # debug-only: force poll-only mode
Reload Behavior
There is currently no hot reload for config.toml. Restart the
daemon after every change:
systemctl restart dvb-warppool
The only hot-switchable items are:
- Profile (
POST /api/admin/profile) — affects display and defaults, not themax_connectionssemaphore - Admin tokens (
POST /api/admin/tokens) — effective immediately - 2FA (
POST /api/auth/2fa/enable) — effective immediately
See Also
- Getting Started — minimum config for first boot
- Notifications — sink-specific examples
- Setup Health Checks — wizard workflow
- Troubleshooting — common failure modes
Setup Health-Checks
Since Phase 9, the first-run wizard (dvb-warppool-setup) doesn't just check
the Bitcoin Core RPC connection itself — it runs a full diagnostic of:
- Bitcoin Core health — everything reachable over RPC
- Local pool network — ports, UPnP gateway
- External reachability — optional, opt-in, with consent
Every check has a clear privacy model.
Privacy matrix
| Check | What it does | Privacy |
|---|---|---|
| Bitcoin health (RPC calls) | Localhost → Bitcoin Core | ✅ Local |
| Port-bind smoke 3333/3334/34254 | tokio::TcpListener::bind locally | ✅ Local |
| UPnP/NAT-PMP discovery | UDP multicast on the LAN | ✅ LAN-only |
| IP echo via api.ipify.org | HTTPS GET → 3rd party sees your IP | ⚠️ Opt-in |
| Port 8333 probe via bitnodes.io | Pings <IP>:8333 | ⚠️ Opt-in |
| Stratum port guide URLs | We send nothing — we only return 3rd-party URLs for manual testing | ✅ Local (operator contacts the 3rd party themselves in the browser) |
Each opt-in check has its own separate checkbox with explicit consent text. Default: all off. Clicking "Run" without consent enabled → alert + no request.
Bitcoin health in detail
A click on "Bitcoin Core health check" makes 5 RPC calls in parallel:
| RPC | What we extract |
|---|---|
getnetworkinfo | version, subversion |
getblockchaininfo | ibd, chain, blocks, headers, verification_progress, size_on_disk, pruned |
getindexinfo | txindex (on/off) |
getpeerinfo | inbound/outbound peer count |
getzmqnotifications | pubhashblock/pubrawblock endpoints |
Plus automatic warnings:
| Condition | Warning |
|---|---|
ibd=true | Mining not yet possible; the pool should only start after sync |
peers.total < 8 | Tip latency increased; new blocks arrive later |
peers.inbound = 0 on mainnet | Port 8333 is probably not forwarded |
zmq.hashblock=None | Pool falls back to RPC polling (job refresh latency) |
pruned=true | Some features (txindex-based) are limited |
verification_progress < 99.9% (and not IBD) | Still syncing |
Local network
A click on "Local check (ports + UPnP)" does:
- Port-bind smoke for
3333(V1 plain),3334(V1 TLS),34254(Sv2 NOISE). We briefly bind on0.0.0.0:<port>and drop immediately. That tells us whether something else is already running there (a parallel gopool, an old daemon, a port conflict). - UPnP discovery via the
igdcrate (pure-Rust, no avahi/libupnp dependency). Returns the gateway IP + external IP if a UPnP-capable router is on the LAN.
The UPnP result is cached internally → the next opt-in probe automatically uses the external IP without an extra api.ipify.org call.
External probes (with consent)
Three opt-in checks:
1. IP echo (api.ipify.org)
Fetches your public IP via HTTPS. You only need this when UPnP doesn't work (no UPnP-capable router) and you still need the external IP to run the other probes or to give miners a connect address.
2. Port 8333 via bitnodes.io
Bitnodes.io is a public Bitcoin node crawler. We do a GET on
https://bitnodes.io/api/v1/nodes/<IP>-8333/. If bitnodes knows your
node (404 = unknown): probably fine. If not: either the port isn't open
or the node is still too new.
⚠ Side effect: your node will then appear publicly on https://bitnodes.io as a known Bitcoin node. That's desired information for any pool operator — other nodes can connect to yours.
3. Stratum port guide
We do NOT scan from the outside, because that would make us a scanning tool ourselves. Instead, we generate a list of 3rd-party tools with your IP + port number pre-filled for a manual self-test:
- yougetsignal.com port checker (direct link with
?remoteAddress=…&portNumber=…) - canyouseeme.org (manual port entry, because we haven't pinned down their URL format)
The operator clicks the links in a new tab — we send nothing, nobody contacts themselves automatically.
What this looks like in code
Backend in apps/dvb-warppool-setup/src/main.rs:
bitcoin_health(BitcoinHealthReq)— the 5 RPC calls +collect_bitcoin_warningsnetwork_health_local()— port-bind + UPnP viaspawn_blockingexternal_probe(ExternalProbeReq)— 422 without consent, otherwise dispatched
The aggregation logic is extracted as a pure function
collect_bitcoin_warnings() and has 8 unit tests for every
warning edge case (IBD, low-peers, no-inbound, no-zmq, pruned,
verification-progress, no-double-warning-on-IBD, healthy-zero-warnings).
UI in apps/dvb-warppool-setup/src/index.html:
- Bitcoin card with RPC test + health-check button
- Network card with local button + probe rows (checkbox + button per probe)
- Status cards with icons (✅⚠️❌), key-value rows, warning list with ⚠ bullet
Phase 10a — bitcoin.conf snippet generator
Instead of just emitting warnings, since Phase 10a the health check generates a copy-paste-ready bitcoin.conf snippet that fixes the identified problems. No direct write — the operator decides what (if anything) to adopt.
What ends up in the snippet
| Health finding | Snippet line |
|---|---|
zmq.hashblock=None | zmqpubhashblock=tcp://127.0.0.1:28332 |
zmq.hashblock=None AND zmq.rawblock=None | + zmqpubrawblock=tcp://127.0.0.1:28333 |
peers.total < 8 AND peers.outbound < 8 | maxconnections=125 |
peers.inbound == 0 AND chain="main" | listen=1 + comment about port forwarding |
What we deliberately don't suggest:
txindex=1— heavy, many pools don't need it (pruned is fine for solo)server=1— if the RPC test got through at all, this must already be truebind=— the defaultall-interfacesfits almost every case
Format
# ===== dvb-WarpPool Setup-Wizard Recommendations =====
# ZMQ block notifications for immediate job refresh
zmqpubhashblock=tcp://127.0.0.1:28332
zmqpubrawblock=tcp://127.0.0.1:28333
# More peer slots so Bitcoin Core gets more stable peer discovery
maxconnections=125
# enable listening so incoming peers can connect
# (additionally: forward port 8333/tcp on the router to this machine)
listen=1
# ===== End Recommendations =====
Header + footer as wrapper comments so the operator spots the section immediately — no risk of accidentally overwriting existing settings (all recommendations sit inside a marked block).
UI
In the browser:
- 📋 box containing the snippet as monospace code
- "Copy" button (
navigator.clipboard.writeText) - OS-aware path hint:
~/.bitcoin/bitcoin.conf(Linux),~/Library/Application Support/Bitcoin/bitcoin.conf(macOS),%APPDATA%\Bitcoin\bitcoin.conf(Windows) — vianavigator.platform - Fallback alert if the clipboard API is unavailable (e.g. http context without a TLS cert)
Phase 11 — UPnP port forwarding
Instead of just discovering the UPnP gateway, since Phase 11 the
setup wizard can also set port mappings directly — via igd::add_port
exposed as an HTTP endpoint. With a whitelist, consent gate, and a
1h lease as default (no permanent default).
Endpoints
POST /api/network-upnp-forward with body:
{
"port": 3333,
"protocol": "tcp",
"description": "dvb-WarpPool Stratum V1",
"consent": true,
"lease_seconds": 3600
}
POST /api/network-upnp-remove to remove (same {port, protocol, consent} pattern).
Security gates
| Layer | What gets checked |
|---|---|
| Consent | consent=true must be in the body — otherwise 422. A router state change is invasive, so the operator must actively agree. |
| Port whitelist | Only 8333 (Bitcoin P2P), 3333, 3334, 34254 (Stratum V1/TLS/V2). Other ports → 400 + list of allowed ports in the error. Prevents the wizard from being misused as a generic port opener. |
| Protocol whitelist | Only tcp/udp (case-insensitive). Others → 400. |
| Lease cap | Default 3600s (1h). Max 86400s (24h, hard cap). The operator has to renew for longer mappings — prevents "fire-and-forget" mappings that stay open for years. |
| Local-IP detection | Via the UDP-connect trick (bind 0.0.0.0:0 + connect 8.8.8.8:80 → local_addr()). No extra dependency. IPv6 outbound → error (UPnP needs IPv4 NAT). |
What we do NOT do
- No persistent storage of the mappings we set — the wizard is
one-shot. For persistent forwards with auto-renewal see Phase 27
(
[upnp]block inconfig.toml). - No multi-router support —
igd::search_gatewaypicks the first UPnP-capable gateway on the LAN. With mesh setups using two routers, only one of them gets hit.
Phase 27 — renew loop in the daemon
The setup wizard opens a port once with a 1h lease. If the pool runs
longer, the lease has to be extended — otherwise the router drops the
forward. Phase 27 adds a renew loop in the daemon that refreshes the
forwards listed in the [upnp] block every renew_interval_secs.
Opt-in (default enabled = false).
Configuration:
[upnp]
enabled = true
renew_interval_secs = 3000 # 50min, safely below the 3600s lease
[[upnp.forwards]]
port = 3333
protocol = "tcp"
lease_seconds = 3600
description = "Stratum V1"
UPnP add_port is idempotent on the router side (same port + protocol =
extend lease, don't create a second entry). The first renew happens 10s
after daemon start (covers crash recovery), then every
renew_interval_secs seconds (range 60..=86400). Errors are only
logged — the loop keeps running so transient problems don't permanently
kill the forward.
Typical router quirks
- FritzBox default: UPnP-discoverable = yes, but
add_port= blocked. The operator has to enable "Allow UPnP status changes via UPnP" in the web UI. We then returnadd_port: The client is not authorized to map this port. - OpenWrt: UPnP off by default, has to be installed as a package
(
miniupnpd). - ISP routers (Telekom Speedport etc.): UPnP often completely disabled; some models have no UPnP at all.
In all of these cases the wizard shows a clear error with the exact router reason. The operator knows what to do.
Phase 12 — bitcoin.conf parse-existing
Instead of suggesting every recommendation, since Phase 12 the
snippet generator filters out lines that are already configured — if
the operator supplies the path to their bitcoin.conf.
How it works
- UI field "bitcoin.conf path" (optional, OS-aware placeholder)
- Path is sent along with the
/api/bitcoin-healthPOST - Server parses the file with
parse_bitcoin_conf_str(&str) -> BTreeMap<String, Vec<String>>— line-based, tolerant of:- Comments (
# ...) - Empty lines + whitespace
- Section headers
[main][test](ignored, all keys flattened) - Multiple values per key (e.g. two
zmqpubhashblock=lines) - Malformed lines (no
=→ ignored, no crash)
- Comments (
generate_bitcoinconf_snippet(&BitcoinHealth, parsed.as_ref())filters per recommendation:if !parsed.contains_key("zmqpubhashblock") → push recommendation
Default behavior is preserved
If the path is not set: all recommendations as before. If the path is set but the file isn't readable: all recommendations + warning "bitcoin.conf at … not readable".
Example
bitcoin.conf before the health check:
server=1
rpcuser=warppool
zmqpubhashblock=tcp://127.0.0.1:28332
maxconnections=200
What previously would have been suggested in the snippet:
zmqpubhashblock=tcp://127.0.0.1:28332(even though it's already there!)zmqpubrawblock=tcp://127.0.0.1:28333(may be missing)maxconnections=125(even though 200 is already set!)listen=1(may be missing)
What with Phase 12 gets suggested:
- only
zmqpubrawblock=tcp://127.0.0.1:28333andlisten=1— the rest is already configured.
What we do NOT do
- No value-compare: if the operator has
maxconnections=10(bad), we still don't suggestmaxconnections=125— we trust that the operator chose that value deliberately. We only fix "missing keys", not "bad values". - No direct write: we only parse read-only, no backup risk.
- No TOML/JSON format: bitcoin.conf has its own line-based format, not TOML.
Phase 13 — daemon periodic health check
The setup wizard runs once at first-run. The daemon runs continuous checks in the running pool — and pushes every change as an SSE event so the UI can show a dashboard widget.
Wiring
13a — crate extraction: The health logic was extracted out of the
setup wizard into its own crate
warppool-health. Since then the setup
wizard is just a thin axum wrapper; the daemon can call the same
functions directly without code duplication.
13b — periodic task: At startup, the daemon spawns a tokio task that runs the health check every N seconds (default 60). Activated via env:
WARPPOOL_HEALTH_CHECK_INTERVAL_SECONDS=60 dvb-warppool-daemon
# 0 = off (default on when env not set → 60s)
Per tick:
- Re-resolve RPC auth from
config.node.rpc_cookie_path(preferred) orsecrets.rpc_user/rpc_pass(the cookie gets rotated on Bitcoin Core restart — we read fresh every time) warppool_health::check_bitcoin_health(&client, &url, &auth).awaitcollect_bitcoin_warnings(&h)event_bus.publish(PoolEvent::HealthSnapshot { ... })
SSE event format
{
"type": "health_snapshot",
"at": "2026-05-27T12:00:00Z",
"rpc_ok": true,
"peers_total": 12,
"peers_inbound": 3,
"ibd": false,
"pruned": false,
"zmq_hashblock_ok": true,
"warnings": [
"Few peers (3/8 recommended) — tip latency increased"
]
}
The UI subscribes via EventSource('/api/events'), dispatches on
event.type === 'health_snapshot', and renders a banner/badge with
the warnings array.
What it deliberately does NOT do
- No bitcoin.conf parse in the periodic check — that's setup-wizard-specific because it generates snippet recommendations. The daemon doesn't need it.
- No UPnP check in 13b — UPnP state changes rarely and is operator-side. The setup wizard checks once.
- No delta detection in 13b — we push every snapshot; the UI does the client-side compare if needed. Saves server state.
- No API endpoint
GET /api/admin/healthin 13b — the UI uses SSE. If needed for CLI scripting, that would be a Phase 13c addition.
Operator example
# Live SSE-Stream auf health-events filtern
curl -N http://localhost:18334/api/events 2>/dev/null \
| grep -A1 'event: health_snapshot'
# event: health_snapshot
# data: {"type":"health_snapshot","at":"...","rpc_ok":true,...}
The operator sees, for example, when after weeks of uptime
zmq_hashblock_ok=false suddenly shows up (Bitcoin Core restarted
without ZMQ config) — and can react without anyone having to monitor
hands-on.
What Phase 14 could bring
- UI dashboard widget for the
health_snapshotevents (banner badge with warning count, click → expanded detail view) - Delta detection in the daemon: only push events when warnings have changed (instead of every tick → saves event-bus traffic on unchanged state)
GET /api/admin/healthfor CLI scripts that check on demand without opening an SSE stream
See also
- Notifications —
rpc-down/rpc-recoveredevents are fired by the same health loop - Observability —
warppool_rpc_ready+ RPC latency histogram - Troubleshooting — when the wizard shows "cookie not readable" or UPnP finds no gateway
Notifications
dvb-WarpPool can notify the operator over several independent channels when
relevant pool events occur — most importantly block found. Configuration
lives in config.toml under [notifier]; secrets (API tokens, webhook URLs,
SMTP passwords) are always referenced via env var, never inline.
Currently active (Phase 15):
| Sink | Protocol | Config key | Secret via env |
|---|---|---|---|
| ntfy | HTTP POST | [notifier.ntfy] | (none — public topic URL suffices) |
| Telegram | Bot API | [notifier.telegram] | bot_token_env |
| Discord | Webhook | [notifier.discord] | webhook_url_env |
| Slack | Incoming Webhook | [notifier.slack] | webhook_url_env |
| SMTP via lettre/TLS | [notifier.email] | password_env (optional) |
Events
The daemon emits five event types:
| Event | When | Priority |
|---|---|---|
block-found | After a successful submitblock in the daemon | high |
miner-disconnect | An authenticated worker closes the Stratum connection; per-worker debounce 30s | medium |
rpc-down | Health check first hits RPC=fail | medium |
rpc-recovered | Health check first hits RPC=ok again after a down state | low |
test | Operator clicks the test button in the admin UI | low |
Each sink can toggle which events it cares about (on_block_found,
on_miner_disconnect, on_rpc_down — all bool). The default everywhere is
on_block_found = true, others off; for Slack/Email all three are toggleable.
Retry: 3 attempts with exponential backoff (2s / 10s / 30s). If everything fails, it gets logged but the daemon keeps running — a notifier failure must never disrupt mining.
ntfy.sh
Simple and account-free. A single topic URL is enough.
[notifier.ntfy]
topic_url = "https://ntfy.sh/my-secret-pool-topic-abc123"
on_block_found = true
on_miner_disconnect = false
on_rpc_down = true
On your phone, install the ntfy app, subscribe to the same topic name, done. Topic names are effectively a shared secret — choose a hard-to-guess string.
For self-hosted ntfy, just use topic_url = "https://ntfy.your-domain.com/topic".
Telegram
Create a bot via @BotFather → note the token. You get a chat_id by sending
the bot a message and then fetching
https://api.telegram.org/bot<TOKEN>/getUpdates (look for "chat":{"id":…}).
[notifier.telegram]
bot_token_env = "TELEGRAM_BOT_TOKEN"
chat_id = "123456789"
on_block_found = true
Then in the daemon environment:
TELEGRAM_BOT_TOKEN="123:abc..." dvb-warppool-daemon
With systemd: Environment="TELEGRAM_BOT_TOKEN=…" in the unit file, or
better an EnvironmentFile=/etc/default/dvb-warppool with chmod 600.
Discord
In the server settings under Integrations → Webhooks, create a new webhook and copy the URL. The URL is effectively the auth token — anyone who has it can post.
[notifier.discord]
webhook_url_env = "DISCORD_WEBHOOK_URL"
on_block_found = true
DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/…" dvb-warppool-daemon
Slack
Create a Slack app in your workspace → enable Incoming Webhooks → generate a webhook for a channel. The payload uses Block Kit (header + section with mrkdwn) for clean layout.
[notifier.slack]
webhook_url_env = "SLACK_WEBHOOK_URL"
on_block_found = true
on_miner_disconnect = false
on_rpc_down = true
Email (SMTP)
lettre-based, with rustls — no openssl-sys. Two URL schemes:
smtps://user@host:465— implicit TLS (common with providers like Posteo, Migadu, Fastmail, Hetzner)smtp://user@host:587— STARTTLS (gmail, many mail servers)
[notifier.email]
smtp_url = "smtps://pool@mail.example.com:465"
from = "pool@example.com"
to = ["operator@example.com", "monitoring@example.com"]
password_env = "POOL_SMTP_PASSWORD"
on_block_found = true
on_miner_disconnect = false
on_rpc_down = true
POOL_SMTP_PASSWORD="…" dvb-warppool-daemon
password_env is optional — if omitted, no SMTP AUTH is attempted (useful
for a local MTA like Postfix on localhost:25 that relays internally).
With multiple recipients, each one gets their own letter — also a simple way to notify the whole team at once.
Test workflow
After configuration:
- Restart the daemon — logs show
notifier sink readyfor each working sink. If an env var is missing or a required config field is empty, you'll seenotifier sink skippedwith a reason; the daemon starts anyway. - In the admin UI under
/admin/notifications→ "Server-Side Sinks (Daemon)" lists all active sinks. - Per sink, a Test button → POST
/api/admin/notifier/test?sink=<name>fires atestevent at just that one. The badge switches to ok/err. - Test all sinks button → POST
/api/admin/notifier/testwith no param.
The same via CLI:
curl -X POST \
-H "Authorization: Bearer wpat_…" \
http://pool.local:18334/api/admin/notifier/test?sink=ntfy
Web Push (PWA, VAPID — Phase 21)
In addition to the 5 server-side sinks above, there's background push straight to your phone via VAPID Web Push. Works even when the PWA is closed (iOS 16.4+, Android, desktop). No 3rd-party service required — push goes directly from the pool daemon to the browser push service (FCM/Mozilla/Apple).
Operator setup
# 1. Generate VAPID keys
dvb-warppool-cli gen-vapid-keys >> /etc/dvb-warppool/secrets.toml
# 2. Optional: set contact mailto (otherwise defaults to mailto:operator@localhost)
echo 'vapid_contact = "mailto:operator@example.org"' >> /etc/dvb-warppool/secrets.toml
# 3. Restart the daemon
systemctl restart dvb-warppool
Daemon log shows web-push sender ready. If not: VAPID keys weren't found
in secrets.toml — push stays disabled, other notifiers run anyway.
User subscribe flow
- Open the pool UI, log in
- Go to
/admin/notifications→ "Enable background push" button - Browser asks for permission → "Allow"
- Subscription is registered with the pool, from now on pushes arrive
Which events fire push
| Event | Tag | When |
|---|---|---|
block-found | block-found | Every block found (requireInteraction=true → notification stays until the user acknowledges it) |
health | health | RPC down OR health snapshot with warnings |
update | update | Pool update available (Phase 8e SSE event) |
Other events (SharesAccepted tick, NewJob, etc.) do NOT fire push — too spammy for background notifications.
iOS quirks
- Required: add the PWA to the home screen (Safari share menu → "Add to Home Screen"). In a regular Safari tab, iOS ignores Web Push.
- iOS 16.4+ is the minimum version (March 2023). On older iOS versions
the UI shows a
Browser unsupportedbadge.
Security note
The VAPID private key is sensitive — anyone holding it can impersonate your
pool to the push services. secrets.toml chmod 600. On suspected leak:
generate new keys + invalidate all subscriptions (a restart is enough —
stale subscriptions get deleted on the first 401 response).
Other Phase B options
- Matrix — not implemented; PR welcome, analogous to the Slack sink pattern.
- PagerDuty / Opsgenie — same; a generic webhook sink with HMAC signing would solve this without vendor lock-in.
See also
- Setup Health Checks — RPC-down/recovered are fired by the same health loop
- Observability — notifier counter
warppool_notifier_events_sent_total - Configuration Reference — all config.toml fields
Observability
dvb-WarpPool exposes its runtime state in two complementary ways:
- Pull — Prometheus-compatible
/metricsendpoint - Push — Notifier sinks (see Notifications) for operator-aware events
A good setup uses both: Prometheus scrapes every 15s for trends and alerts, and critical events (block found, RPC down) go out immediately as notifications.
/metrics Endpoint
Path: GET /metrics on the regular API port (default 18334). Format:
Prometheus text exposition text/plain; version=0.0.4.
Authentication: none — the endpoint is read-only and contains no secrets. If your pool network is public and you don't like that, put a reverse proxy with basic auth in front of it.
Base counters (always present)
| Metric | Type | Description |
|---|---|---|
warppool_blocks_found_total | counter | Accepted blocks since the first daemon start |
warppool_shares_accepted_total | counter | Accepted shares across all workers |
warppool_shares_rejected_total | counter | Stale / low-diff / malformed |
warppool_workers_total | gauge | Number of workers ever seen |
warppool_rpc_ready | gauge | 1 if Bitcoin Core RPC is reachable |
warppool_rpc_ibd | gauge | 1 if Bitcoin Core is in initial block download |
warppool_network_height | gauge | Chain tip height according to our node |
warppool_network_difficulty | gauge | Current network difficulty |
warppool_current_job_height | gauge | Height of the template currently being served |
warppool_current_job_coinbase_value_sats | gauge | Coinbase reward in sats |
warppool_started_at_seconds | gauge | Daemon start as a unix timestamp |
warppool_last_template_at_seconds | gauge | Last successful getblocktemplate |
warppool_build_info{brand,profile,chain} | gauge | Constant 1, all constants in labels |
Phase 16: extended pool metrics
These are active as soon as the daemon hands PoolMetrics to the API state
(automatic when the daemon binary is running; optional in test setups).
| Metric | Type | Description |
|---|---|---|
warppool_workers_authorized_total | counter | Cumulative mining.authorize successes (v1) + OpenChannel successes (v2) |
warppool_workers_disconnected_total | counter | Cumulative authenticated worker disconnects |
warppool_active_connections{protocol="v1"} | gauge | Open Stratum V1 connections |
warppool_active_connections{protocol="v2"} | gauge | Open Stratum V2 connections |
warppool_bitcoin_rpc_latency_seconds | histogram | End-to-end RPC call duration (all retries included) |
Histogram buckets (seconds): 0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, +Inf. Prometheus cumulative semantics — each observation increments every bucket ≥ its value.
Example query (Grafana):
# p99 RPC latency, last 5 minutes
histogram_quantile(0.99, rate(warppool_bitcoin_rpc_latency_seconds_bucket[5m]))
# RPC call rate
rate(warppool_bitcoin_rpc_latency_seconds_count[1m])
Phase 22: per-miner vendor probe metrics
When the daemon's miner_poll_loop is running (default), configured miners
are polled every 30s and their telemetry values are exposed as gauges:
| Metric | Type | Labels | Description |
|---|---|---|---|
warppool_miner_hashrate_ghs | gauge | label, host, vendor, model | Miner-reported hashrate in GH/s |
warppool_miner_temperature_c | gauge | label, host, vendor, model | ASIC core temperature in °C |
warppool_miner_power_w | gauge | label, host, vendor, model | Power draw in watts |
warppool_miner_voltage_mv | gauge | label, host, vendor, model | ASIC core voltage in mV |
warppool_miner_fan_rpm | gauge | label, host, vendor, model | Fan speed in RPM |
warppool_miner_last_probe_age_seconds | gauge | label, host, vendor | Seconds since the last successful probe |
warppool_miner_probe_health | gauge | label, host, vendor | 1 if OK and recent (<5min); 0 if error or stale |
None fields are skipped — if a miner doesn't report voltage_mv, for
example, the metric is simply omitted for that miner (instead of 0, which
would wreck the operator's trend lines).
If WARPPOOL_AUTO_PROBE_DISCOVERED=true is set, miners discovered via mDNS
are also included with label="discovered" — the operator can separate them
with:
# Configured miners only
warppool_miner_hashrate_ghs{label!="discovered"}
# Discovered miners (not in the DB)
warppool_miner_hashrate_ghs{label="discovered"}
Example queries:
# Total pool hashrate (sum of all miners)
sum(warppool_miner_hashrate_ghs)
# Maximum temperature across all miners — operator alarm if > 85°C
max(warppool_miner_temperature_c)
# Hashrate per worker per watt (efficiency)
warppool_miner_hashrate_ghs / warppool_miner_power_w
# Which miners have a failing probe cycle?
warppool_miner_probe_health == 0
Phase 15/16: notifier metrics
When a notifier is configured:
| Metric | Type | Description |
|---|---|---|
warppool_notifier_sinks_active | gauge | Number of initialized sinks |
warppool_notifier_events_sent_total{sink,event,result} | counter | Send attempts per (sink, event kind, outcome) |
result = "ok" or "err". event is one of block-found,
miner-disconnect, rpc-down, rpc-recovered, test.
Example query: sink failure rate (a hint at wrong env vars or blocked webhooks):
rate(warppool_notifier_events_sent_total{result="err"}[5m])
/ ignoring(result) group_left
rate(warppool_notifier_events_sent_total[5m])
Grafana Dashboard
A starter dashboard with the most important panels:
{
"title": "dvb-WarpPool",
"panels": [
{
"title": "Blocks Found",
"type": "stat",
"targets": [{ "expr": "warppool_blocks_found_total" }]
},
{
"title": "Hashrate (approx, last 5min)",
"type": "timeseries",
"targets": [{
"expr": "rate(warppool_shares_accepted_total[5m]) * pow(2, 32)"
}]
},
{
"title": "Active Connections",
"type": "timeseries",
"targets": [
{ "expr": "warppool_active_connections{protocol=\"v1\"}", "legendFormat": "v1" },
{ "expr": "warppool_active_connections{protocol=\"v2\"}", "legendFormat": "v2" }
]
},
{
"title": "RPC Latency (p50/p99)",
"type": "timeseries",
"targets": [
{ "expr": "histogram_quantile(0.50, rate(warppool_bitcoin_rpc_latency_seconds_bucket[5m]))", "legendFormat": "p50" },
{ "expr": "histogram_quantile(0.99, rate(warppool_bitcoin_rpc_latency_seconds_bucket[5m]))", "legendFormat": "p99" }
]
},
{
"title": "Bitcoin Core Health",
"type": "stat",
"targets": [
{ "expr": "warppool_rpc_ready" },
{ "expr": "warppool_rpc_ibd" }
]
}
]
}
(A full dashboard with variables and annotations may follow later as
packaging/grafana/dashboard.json.)
Prometheus scrape config
scrape_configs:
- job_name: dvb-warppool
scrape_interval: 15s
static_configs:
- targets: ['pool.local:18334']
Alert recipes
RPC unreachable > 2min
- alert: WarppoolRpcDown
expr: warppool_rpc_ready == 0
for: 2m
annotations:
summary: "Pool {{ $labels.instance }} has no RPC connection to the Bitcoin node"
No shares > 10min (miner offline?)
- alert: WarppoolNoShares
expr: rate(warppool_shares_accepted_total[10m]) == 0
for: 10m
annotations:
summary: "Pool {{ $labels.instance }} is receiving no shares"
RPC latency p99 > 1s
- alert: WarppoolRpcSlow
expr: |
histogram_quantile(0.99,
rate(warppool_bitcoin_rpc_latency_seconds_bucket[5m])
) > 1
for: 5m
Notifier sink failing persistently
- alert: WarppoolNotifierBroken
expr: |
rate(warppool_notifier_events_sent_total{result="err"}[15m])
/ ignoring(result) group_left
rate(warppool_notifier_events_sent_total[15m]) > 0.5
for: 15m
annotations:
summary: "Notifier sink {{ $labels.sink }} failing >50% — check config"
SSE Events (separate story)
Alongside /metrics, /api/events runs a Server-Sent-Events stream that
pushes live events to the UI (block_found, new_job, shares_accepted,
...). It's primarily intended for the UI banners; for monitoring, use
/metrics — Prometheus is more robust against scraping pauses.
See also
Troubleshooting
A pragmatic fix-guide for the most common problems. For each symptom: brief explanation → diagnostics → fix.
General: reading logs
journalctl -u dvb-warppool -f --since "5 min ago"
For Docker:
docker logs -f dvb-warppool --since 5m
Temporarily raise the log level:
RUST_LOG=warppool=debug,info dvb-warppool-daemon
Targeted per crate:
RUST_LOG=warppool_stratum_v1=trace,warppool_bitcoin_rpc=debug,info dvb-warppool-daemon
RPC
getblockchaininfo failed: HTTP 401 — credentials rejected
Cookie or user/pass mismatch.
Diagnosis:
# Cookie auth:
cat ~/.bitcoin/.cookie # should be "user:hexhash"
# user/pass auth:
grep -E "^rpc(user|password)" ~/.bitcoin/bitcoin.conf
Fix:
- Cookie mode: make sure
node.rpc_cookie_pathinconfig.tomlpoints to the correct file. Bitcoin Core has its own cookie files for regtest/testnet (~/.bitcoin/regtest/.cookie,~/.bitcoin/testnet3/.cookie). The setup wizard typically shows the right path. - user/pass mode: in
secrets.toml,rpc_user+rpc_passmust match the values inbitcoin.conf. Insecrets.toml, notconfig.toml— otherwise the daemon complains at startup. - On permission errors: bitcoind runs as a different user; the cookie is
0600and unreadable for your warppool user. The setup wizard catches this and suggestschmod g+r+ group-add.
getblocktemplate failed: -8 ...
Bitcoin Core is reachable, but says no to the template.
Possible causes:
- IBD running:
warppool_rpc_ibd == 1. Wait until sync completes. - Wallet locked:
getblocktemplatewithout-walletbroadcast=0needs an unlocked wallet in Core. Unusual in a pool context, though — we use GBT without a wallet. - Too few peers:
getpeerinfoshows 0 or 1. Core then refuses to mine to avoid ending up on a minority fork. Fix: wait, or use-minimumchainwork=0for tests.
RPC latency high (Prometheus shows p99 > 1s)
Diagnosis:
- Are many RPCs running in parallel? The daemon normally does ~1-5/s in steady state. If p99 is high: is Bitcoin Core on a slow disk or swapping?
tx-index=1setup on HDD: makes GBT slow. SSD recommended.- Pruned node + old block request: doesn't happen on the normal pool path, but backup/restore workflows can trigger it.
Fix: usually a disk bottleneck. iotop / iostat shows this quickly.
ZMQ
Daemon only uses poll-loop, no ZMQ
Log line at startup:
zmq watcher: WARPPOOL_ZMQ_HASHBLOCK_ADDR not set, falling back to poll-only
Fix: set in config.toml:
[node]
zmq_hashblock_addr = "tcp://127.0.0.1:28332"
And in bitcoin.conf:
zmqpubhashblock=tcp://127.0.0.1:28332
Bitcoin Core must be restarted after this change.
ZMQ connection refused / timeout
Bitcoin Core isn't listening on the ZMQ port. Diagnosis:
ss -tlnp | grep 28332
# Expected output: bitcoind listens on 0.0.0.0:28332 or 127.0.0.1:28332
If nothing: the bitcoin.conf line is missing or Bitcoin Core wasn't
restarted.
If 0.0.0.0: ZMQ is exposed — often necessary with a Docker bridge (daemon
and bitcoind in different containers). On a direct host
tcp://127.0.0.1:28332 should be enough.
Stratum / miner connections
Workers reconnect-loop every few seconds
Symptom: many session ended with shares_accepted = 0 in the logs. Many
miner-disconnect events in the notifier (or, with Phase 15 debounce, one every 30s).
Common causes:
- Wrong worker address: a V1 miner submits an address the ShareValidator
doesn't accept. Logs show
mining.authorizewith a bad address. - VarDiff too aggressive: the initial diff was too high, the miner can't
produce shares within the
target_seconds_per_sharewindow, retargets down quickly, and some firmwares don't like rapid retargets. Fix: a highermin_diffor a lowerinitial_diffin[vardiff]. - Auth rate limit kicks in: at >
auths_per_secauth attempts per IP you get rejected. In the log:auth rate-limited. For legitimate cases with multiple workers behind NAT: raise the rate limit.
Miner can't connect at all
# Is the pool port reachable?
nc -v pool.local 3333
# TLS port?
openssl s_client -connect pool.local:3334 -showcerts
If local is OK but external isn't: firewall / NAT. The UPnP wizard in setup (Phase 11) can open this automatically, but only if the FritzBox / router allows UPnP. Some ISP routers have UPnP off by default.
Notifier doesn't fire
notifier sink skipped: env var foo not set
At daemon start, the env vars for the sink aren't present. With systemd:
[Service]
EnvironmentFile=/etc/default/dvb-warppool
With /etc/default/dvb-warppool as a chmod-600 file:
TELEGRAM_BOT_TOKEN=123:abc...
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
POOL_SMTP_PASSWORD=...
For Docker: via -e KEY=val or an env-file in docker-compose.yml.
Test button shows err
Click Test in the admin UI → err badge. The specific error shows as a
tooltip on the badge (hover). Common cases:
- Telegram:
bot not found— wrong bot_token.chat not found— wrong chat_id or the bot was removed from the chat. - Discord:
Unknown Webhook— webhook was deleted. - Slack:
invalid_payload— very rare; usually a typo in the webhook URL. - Email:
Could not resolve host— wrong SMTP host.authentication failed— wrong password env.unable to connect: connection refused— wrong port or firewall.
Spam from miner-disconnect events
Per-worker debounce is 30s (WARPPOOL_DISCONNECT_DEBOUNCE_SECS). If that's
too noisy: set the env var to 300 (5min).
If a single worker keeps disconnecting: that's a real problem on the miner
side — check the logs on the miner hardware (the Bitaxe web interface has
Logs; Antminer has SSH access).
Auto-update
Update fails: download/verify: sha256 mismatch
The asset on GitHub was changed between when you saw the release and
downloaded it (rare), or you passed the wrong sha256. For POST /api/admin/update,
always take the sha256 from the release notes file or from the GitHub
API itself — never type it manually.
Update applied but daemon keeps restarting (StartLimitBurst)
The Phase 8f rollback hook fired. The systemd journal shows:
OnFailure=dvb-warppool-rollback.service triggered
rollback.sh copies the .backup back and runs systemctl restart. If the
backup is also broken: install the old version manually or roll back from
a snapshot.
cosign verify-blob failed
When cosign_verify=true is set in the update request: the signature doesn't
match the asset. Possible causes:
- The asset is authentic but the signature file is stale
- The asset has been tampered with
The default is cosign_verify=false — only use cosign if you've explicitly
configured the trust anchor (public key or Sigstore root).
Setup wizard
"Cookie not readable"
Most likely a permission issue. The setup wizard shows the exact path + suggested fix:
sudo usermod -a -G bitcoin warppool # one-time
sudo chmod g+r ~/.bitcoin/.cookie # after every Core restart
Cleaner: configure bitcoind with rpcauth=warppool:saltedhash and use
user/pass mode instead of the cookie.
UPnP test "no gateway found"
The router has UPnP off, or the daemon is bound to the wrong interface. Some ISP routers (Vodafone, some FritzBoxes with a locked-down user account) don't allow UPnP from the outside.
Fix:
- Enable UPnP in the router web interface
- Alternatively: set port forwarding manually (3333/3334)
bitcoin.conf snippet suggests recommendations for keys that are already set
If the setup wizard says "add zmqpubhashblock=..." even though it's already
there — usually the wrong conf path. Check bitcoin-cli -datadir=… getrpcinfo
to see the datadir that's actually in use.
API / UI
/api/admin/* returns 401
Admin auth is configured but the token is missing from the request:
curl -H "Authorization: Bearer wpat_…" http://pool.local:18334/api/admin/notifier/sinks
Generate a token with dvb-warppool-cli token-create -n my-script — the raw
token is shown only once.
/api/admin/* returns 503 "auth disabled"
secrets.toml has no admin_password_hash or jwt_secret. Fix:
dvb-warppool-cli hash-password
and paste the output into secrets.toml.
UI doesn't load — Failed to fetch /api/...
Direct --ui-dir mode or dev mode? In dev (pnpm dev), Vite runs with a
proxy /api → http://127.0.0.1:18334. In production: the UI is served
directly by the daemon via --ui-dir. Check the daemon startup log:
serving static UI from daemon ui_dir=/usr/share/dvb-warppool/ui
If not present: the --ui-dir flag is missing or the directory doesn't exist.
Vendor probes (Phase 22)
Miner is discovered via mDNS but no telemetry
Possible causes:
| Vendor | mDNS discovery | Telemetry probe |
|---|---|---|
| Bitaxe | ✅ _bitaxe._tcp.local. | ✅ AxeOS HTTP /api/system/info |
| NerdNOS / NerdOctaxe | ✅ _nerdminer._tcp.local. | ✅ AxeOS-compatible |
| Antminer S19/S21/S23 | ❌ no mDNS | ✅ CGMiner socket 4028 — add manually in /admin/miners with vendor=antminer |
| Whatsminer M30S/M50 | ❌ | ✅ CGMiner socket — vendor=whatsminer |
| Avalon A12xx/A13xx standalone | ❌ | ✅ CGMiner socket — vendor=avalon |
| Avalon Q (home miner) | ❌ | ✅ CGMiner socket but its own field names — vendor=avalonq |
| Avalon A15xx (e.g. A1566) | ❌ | 🟡 try vendor=avalon — if the field naming differs, please file an issue with cargo run -- probe --vendor avalon --host … output |
| Braiins OS / OS+ | 🟡 via HTTP fallback when the hostname contains braiins* / bos-* | ✅ CGMiner socket 4028 with Braiins-specific field names (power_consumption_w, voltage, temp1..N) — vendor=braiins |
vendor=avalon and vendor=avalonq are separate adapters — one matches
MTavg/MTmax/GHS av, the other THSspd/TMax/Cur_Load. If you add
a Q with vendor=avalon, lots of None values come back.
Manual add command
curl -X POST -H "Authorization: Bearer wpat_..." \
-H "Content-Type: application/json" \
-d '{"host":"192.168.1.42:4028","vendor":"avalonq","label":"avalonq-1"}' \
http://pool.local:18334/api/admin/miners
Port 4028 is the CGMiner default. Some vendors reconfigure it — in that case, pass the correct port number.
Last resort
Daemon panicked and won't come up
Look at the logs. Common cases:
- DB migration failed:
sqlx::migratethrows on an inconsistent schema - Config file broken: TOML parse error with line number
- Port taken: another pool / service on 3333
Rebuild storage DB from scratch
systemctl stop dvb-warppool
mv /var/lib/dvb-warppool/pool.db /var/lib/dvb-warppool/pool.db.broken
systemctl start dvb-warppool # daemon creates a fresh DB and runs migrations
You will lose:
- VarDiff snapshots per worker (every miner starts at initial_diff)
- Aggregated hashrate history (raw shares > 1h are evicted anyway)
- Audit log
Blocks-found is NOT the source of truth in the DB — those live in the
Bitcoin blockchain itself. The block list is lazily re-populated on the
first request via getblock RPCs.
See also
- Setup health checks — preventive diagnostics during setup
- Auto-update — update path + rollback
- Configuration Reference
Packaging & Installation
dvb-WarpPool ships in multiple formats. The release pipeline
(.github/workflows/release.yml) builds all
artifacts listed below in parallel on every v* tag and signs them with
Cosign keyless OIDC.
Update paths: After the initial install via one of the formats below, you can apply updates in two ways:
- Package manager (
apt/dnf/docker pull/Umbrel app updater) — classic, idempotent, controlled- Built-in auto-update since Phase 8 — the admin UI shows an update banner; one click runs download + sha256 verify + optional cosign verify + atomic_swap + systemd hint. See Auto-Update for the workflow.
Overview
| Format | Architecture | Status | Notes |
|---|---|---|---|
| Docker | amd64 + arm64 | ✅ | ghcr.io/<org>/dvb-warppool:<tag> |
.deb | amd64 + arm64 | ✅ | apt/dpkg, systemd unit included |
.rpm | amd64 + arm64 | ✅ | dnf/rpm, systemd unit included |
.AppImage | x86_64 + aarch64 | ✅ | Portable, no root required (RPi 5) |
.dmg | aarch64 + x86_64 | ⚠ unsigned | Phase B: Notarization (Dev-ID) |
.msi | x64 | ⚠ unsigned | Phase B: code-signing certificate |
| Tarball | Linux + macOS | ✅ | Manual installs |
| Umbrel | amd64 + arm64 | ✅ | packaging/umbrel/dvb-warppool/ |
Linux .deb (Debian / Ubuntu / Mint)
# download from GitHub Releases:
wget https://github.com/dvb-projekt/dvb-WarpPool/releases/download/v0.1.0/dvb-warppool_0.1.0-1_amd64.deb
sudo apt install ./dvb-warppool_0.1.0-1_amd64.deb
# first-run wizard (Bitcoin Core RPC, pool profile, payout address):
sudo dvb-warppool-setup
# start the daemon:
sudo systemctl enable --now dvb-warppool
# UI: http://localhost:18334
What the postinst does:
- creates the system user
warppool - creates
/var/lib/dvb-warppool/(data, mode 0700, owner warppool) and/var/log/dvb-warppool/ chgrp warppool /etc/dvb-warppoolso the setup wizard can atomically write into it viasudosystemctl daemon-reload
Logs: journalctl -u dvb-warppool -f
Linux .rpm (Fedora / RHEL / openSUSE)
sudo dnf install ./dvb-warppool-0.1.0-1.x86_64.rpm
sudo dvb-warppool-setup
sudo systemctl enable --now dvb-warppool
post_install_script does the same as the .deb postinst (system user,
directories, daemon-reload).
Linux AppImage (portable, no-root)
chmod +x dvb-WarpPool-0.1.0-x86_64.AppImage
./dvb-WarpPool-0.1.0-x86_64.AppImage
Also available as an aarch64 AppImage for ARM boards (Raspberry Pi 5, Pine64, Rock-Pi):
chmod +x dvb-WarpPool-0.1.0-aarch64.AppImage
./dvb-WarpPool-0.1.0-aarch64.AppImage
The first launch opens the setup wizard if no
~/.config/dvb-warppool/config.toml exists. After that the daemon starts
directly and the UI is reachable at http://localhost:18334.
Raspberry Pi 5 (Raspberry Pi OS / Ubuntu 24.04 for arm64)
dvb-WarpPool runs very well on the Pi 5 — the Cortex-A76 cores have
significantly better single-thread performance than the Pi 4 (relevant for
Stratum connection handling and Bitcoin RPC). 8 GB RAM is enough for the
gross profile with a few hundred workers; with 16 GB, enterprise is
realistic.
Fastest path: .deb package
# Raspberry Pi OS / Ubuntu / Debian for arm64:
wget https://github.com/dvb-projekt/dvb-WarpPool/releases/download/v0.1.0/dvb-warppool_0.1.0-1_arm64.deb
sudo apt install ./dvb-warppool_0.1.0-1_arm64.deb
sudo dvb-warppool-setup
sudo systemctl enable --now dvb-warppool
Alternative: Docker (with Bitcoin Core sidecar)
docker run -d --name warppool \
--restart unless-stopped \
-p 3333:3333 -p 18334:18334 \
-v /etc/dvb-warppool:/config:ro \
-v warppool-data:/data \
ghcr.io/dvb/dvb-warppool:v0.1.0
Multi-arch image — Docker automatically pulls the arm64 variant on the Pi.
Storage recommendation
- NVMe HAT (e.g. Pimoroni NVMe Base, Geekworm X1003) strongly recommended.
The pool DB (SQLite WAL +
shares_raweviction every 60s) puts heavy pressure on SD cards — a typical SD only lasts months under pool load, an NVMe lasts years. - If using SD: high-quality industrial-grade SD (e.g. Sandisk Industrial, Western Digital Endurance) — no cheap consumer SDs.
- A USB-3 SSD is the middle-ground option if no NVMe HAT is available.
Bitcoin Core on the same Pi
8 GB RAM is enough for both (pool + Bitcoin Core with prune=10000), as
long as the mempool isn't extremely full. 16 GB is more comfortable.
Bitcoin Core on its own needs 5–7 GB under normal load.
With a separate Bitcoin Core host (e.g. an Umbrel / Start9 on the LAN), 4 GB RAM is also enough on the Pi 5 for the pool alone.
hwdetect detects the Pi automatically
The setup wizard reads /proc/device-tree/model and identifies the Pi
model. On the Pi 5, Environment::RaspberryPi is set, and the profile
recommendation system offers gross as the feasible maximum, plus the
NVMe storage hint as Severity::Info.
Pi 4 works too
The Pi 4 is tested, but the Pi 5 is recommended (higher per-core
performance). Pi 3 and Pi Zero only make sense for klein profiles and
are not intended for production use.
macOS .dmg
⚠ Unsigned — Gatekeeper warning on first launch. Notarization with an Apple Developer ID is planned for Phase B.
- Open the
.dmg→ dragdvb-WarpPool.appinto the Applications folder - Right-click → "Open" → confirm "Open" (Gatekeeper override)
- The first launch opens a Terminal with the setup wizard
- Subsequent launches: double-clicking the app opens the UI in the browser automatically
State: ~/Library/Application Support/dvb-WarpPool/ (config + data + logs)
Windows .msi
⚠ Unsigned — SmartScreen warning on first launch. Code-signing with an EV certificate is planned for Phase B.
- Double-click the
.msi→ "More info" → "Run anyway" - Installs to
C:\Program Files\dvb-WarpPool\ - Start the setup wizard manually: Start menu →
dvb-warppool-setup - Daemon via Task Scheduler or Command Prompt:
dvb-warppool-daemon.exe --config C:\ProgramData\dvb-warppool\config.toml
Docker
docker run -d --name warppool \
-p 3333:3333 -p 18334:18334 \
-v /etc/dvb-warppool:/config:ro \
-v warppool-data:/data \
ghcr.io/dvb/dvb-warppool:v0.1.0 \
--config /config/config.toml --data-dir /data
Multi-arch (amd64 + arm64) is selected automatically — no --platform needed.
Signature verification (Cosign)
Every release includes SHA256SUMS plus a Cosign keyless signature
(SHA256SUMS.sig + SHA256SUMS.pem). Verification without locally stored
keys (OIDC trust):
cosign verify-blob \
--certificate SHA256SUMS.pem \
--signature SHA256SUMS.sig \
--certificate-identity-regexp "https://github.com/dvb-projekt/dvb-WarpPool/.github/workflows/release.yml@.*" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
SHA256SUMS
# then against the individual files:
sha256sum --check SHA256SUMS
SLSA build provenance
Every release ships with SLSA-3 provenance via
slsa-github-generator.
The provenance is in the release assets area (*.intoto.jsonl) and can
be checked with slsa-verifier:
slsa-verifier verify-artifact \
--provenance-path multiple.intoto.jsonl \
--source-uri github.com/dvb-projekt/dvb-WarpPool \
--source-tag v0.1.0 \
dvb-warppool_0.1.0-1_amd64.deb
SBOM (SPDX)
sbom.spdx.json in every release lists all crates with their versions and
licenses (via anchore/sbom-action).
Usable with syft / grype for vulnerability audits.
Phase B — open loose ends
- macOS Notarization:
xcrun notarytool submitwith a Developer ID certificate + Apple ID + app-specific password. The secretsAPPLE_ID,APPLE_TEAM_ID,APPLE_NOTARY_PASSWORDneed to be set in the GitHub repo secrets. - Windows code-signing: signtool sign with an EV certificate. Azure Code Sign is an alternative for CI-friendly token-based signing.
- Logo / icon design: Currently only a 1×1 placeholder in the AppImage;
macOS and Windows have no icon. Once the design is finalized (see
ROADMAP.md), it needs to be placed under
packaging/{appimage,macos,windows}/.
Security
Operator-focused overview of what dvb-WarpPool protects, how it does so, and which responsibilities remain with the operator.
Threat Model
The pool sits between four actor classes with different levels of trust:
| Actor | Trust | Threat |
|---|---|---|
| Pool operator | high | Has shell access, can modify config and secrets. Threat: secrets leak via backup, swap file, or a compromised SSH account |
| Admin user | medium | Logs in via browser, can manage profile, tokens, and auto-update. Threat: compromised browser cookie or leaked API token |
| Miner | low | Authenticates only via Bitcoin address as username. Threat: stale-share flood, reconnect loop, malformed Stratum, ASIC-Boost abuse |
| Internet | hostile | Unauthenticated. Threat: DoS flood, TLS downgrade, reverse-proxy IP spoofing, supply chain via auto-update |
Threat → Mitigation Matrix
| Threat | Mitigation | Phase |
|---|---|---|
| Stratum DoS flood | Rate limiting per peer IP ([ratelimit]), connection cap from profile, configurable burst | 3 |
| Auth bruteforce | Auth-RPM limit per IP — on exceedance, mining.authorize is acknowledged with result: false (no disconnect loop), Argon2id for admin password | 3 |
| TLS downgrade | Rustls 0.23 with default suite set (TLS 1.3 only, no TLS 1.0/1.1, no RSA key exchange) | 3 |
| RPC credential leak | Cookie auth preferred (auto-rotation on bitcoind restart), otherwise secrets.toml chmod 600 | 1 |
| Supply chain | cargo deny in CI with license allowlist + advisories, signed releases (cosign-keyless OIDC), reproducible builds | 6, 8b, 8g |
| Memory safety | Rust with no unsafe except in NOISE/ZMQ bindings that are wrapped by upstream crates | — |
| Stratum replay | Dedup per (extranonce2, ntime, nonce) tuple in share validator | 2 |
| Reorg attack | Block-found events only after submitblock OK, UI marks pending/accepted/rejected separately | 2 |
| Insecure UI origin | CORS permissive only in dev mode; production runs daemon-static-served UI = same-origin → no CORS issue | 5 |
| Admin session hijack | JWT with short TTL (default 24h) signed with jwt_secret (32+ bytes), cookie HttpOnly + SameSite=Lax + optional Secure (server.cookie_secure when the panel is served behind TLS) | 3.1, 32 |
| API token theft | Token hash (sha256) instead of plaintext in DB, soft-revoke via revoked_at, last_used_at tracking for audit | 3.2 |
| Account takeover | Optional 2FA-TOTP per user (/api/auth/2fa/{setup,enable,disable}) — with 2FA active, login without totp_code fails with 401+requires_2fa:true | 3.3 |
| Audit bypass | All state-changing routes emit audit_log (actor, action, target, peer_ip, ok, details) | 3 |
| IP spoofing via X-Forwarded-For | Default trust_proxy_headers = false; only enable when behind a trusted proxy | 2.5 |
| Notifier secrets in backup | Token/webhook URL/SMTP password are all env-var references — config.toml contains no secrets | 15 |
| Auto-update tampering | Mandatory sha256 verify; optional cosign-verify-blob (env-gated) before atomic_swap; OnFailure rollback via systemd | 8c, 8f, 8g |
| Privilege escalation via setup | UPnP whitelist on 4 ports + consent gate, external probes opt-in per probe with consent | 9, 11 |
| Consensus correctness (silent reward loss) | Coinbase merkle commits to txid (byte-reversed, internal order) — NOT wtxid; tested against real mainnet block 100000. pooltag_prefix validated ≤64 bytes (coinbase scriptSig 100-byte limit). Either would otherwise invalidate EVERY block found | 32 |
| Memory DoS (Stratum) | V1+Translator line cap 16 KiB (LinesCodec), V1 pre-auth handshake timeout 60s (no post-auth idle timeout → low-HR miners stay), Sv2 connection cap (semaphore) + read_buf cap (64 KiB), dedup-set reset on job switch | 32 |
| Sv2 coinbase manipulation | Miner extranonce length is checked against the negotiated channel size (invalid-extranonce-size, usize comparison, no truncation) | 32 |
| Web Push SSRF | is_safe_push_endpoint (https + block private/loopback/link-local/CGNAT/IPv6-ULA/localhost) on subscribe AND send + DNS resolve guard against rebinding | 32 |
| Login bruteforce / CPU DoS (HTTP) | Per-IP throttle (10 attempts / 5 min) BEFORE the Argon2 path; constant-time login (Argon2 always runs, no username-enumeration timing) | 32 |
| Local file disclosure (DB / 2FA secrets) | Data directory 0700 + SQLite DB+WAL+SHM 0600 (unix, enforced); setup wizard no longer overwrites an existing secrets.toml | 32 |
Auth Stack
dvb-WarpPool has three orthogonal auth paths:
- JWT cookie — browser UI, normal login flow
- API tokens — bearer token for scripts (
wpat_<32hex>prefix) - 2FA-TOTP — optional additional factor for (1)
Login Flow
POST /api/auth/login {username, password, totp_code?}
-> verify Argon2id hash against admin_password_hash
-> 2FA active? check totp_code (RFC 6238, ±1 step skew)
-> mint JWT mit sub=username, exp=now+24h
-> set HttpOnly Cookie warppool_session=<jwt>
-> audit: login.ok | login.fail
API Token Flow
POST /api/admin/tokens {name, ttl_secs?, scope?} (auth required)
-> generate token: format "wpat_" + 32 hex chars (128 bits entropy)
-> store sha256(token) + name + scope + expires_at
-> response: {id, name, scope, token, expires_at}
-> CLIENT MUST STORE THE TOKEN IMMEDIATELY — only the hash is kept afterwards
-> audit: token.create
Authorization: Bearer wpat_abc123... (subsequent requests)
-> sha256(token) lookup in api_tokens WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now)
-> match found → AuthIdentity{sub: "token:<name>", exp: ...}
-> fire-and-forget touch_api_token (last_used_at = now)
2FA Setup
POST /api/auth/2fa/setup
-> mint_2fa_setup(issuer="dvb-WarpPool", account=username)
-> generate base32 secret + otpauth:// URL
-> persist with enabled=0
-> audit: 2fa.setup
(User scans QR or enters secret in Authenticator app)
POST /api/auth/2fa/enable {code}
-> verify_totp(secret, code) → ok
-> UPDATE admin_2fa SET enabled=1, enabled_at=now
-> audit: 2fa.enable
Key Material
| Key | Where | Protection | What happens on leak |
|---|---|---|---|
Pool payout_address | config.toml | no protection needed — it is public | Nothing — incoming rewards always land at the address |
| Bitcoin RPC cookie | ~/.bitcoin/.cookie | chmod 0640 (bitcoind default) | Full RPC access to Bitcoin Core. Pool operator and Bitcoin user should be in the same group. |
rpc_user / rpc_pass | secrets.toml | chmod 0600 | Full RPC access. Bitcoin Core uses rpcauth= PHC hashes — leaking the hash source renders the pool password useless. |
admin_password_hash | secrets.toml | chmod 0600 | Argon2id m=64MiB, t=3 — bruteforce-resistant. But: leaking the hash plus jwt_secret lets the attacker sign valid cookies without breaking the hash. |
jwt_secret | secrets.toml | chmod 0600 | On leak: attacker can mint valid sessions without the password. Rotation: change jwt_secret → all sessions invalid (operator must log in again). |
sv2_static_priv_key_hex | secrets.toml | chmod 0600 | With the Sv2-NOISE static key someone can impersonate the pool toward V2 miners. Generated with dvb-warppool-cli gen-sv2-key. |
| TLS cert + key | operator-configurable | operator responsibility | If cert/key/CSR process is leaked, MITM on Stratum-TLS is possible |
| 2FA-TOTP secret | admin_2fa DB | DB chmod 0600 | Per user, base32. On DB leak + admin-pw hash + jwt-secret the attacker can generate 2FA codes — all three must be protected. |
| Notifier tokens | env vars, NEVER config.toml | systemd EnvironmentFile chmod 0600 | Telegram/Discord/Slack spam from the operator's identity, email sending. Nothing pool-internal. |
| Web Push VAPID | Phase B (not yet active) | would be secrets.toml | With the VAPID private key, anyone who has the subscriptions can send fake push notifications |
Audit Logging
All state-changing admin actions are persisted in audit_log:
| Action | Trigger |
|---|---|
login.ok / login.fail | /api/auth/login |
2fa.setup / 2fa.enable / 2fa.disable | /api/auth/2fa/* |
token.create / token.revoke | /api/admin/tokens/* |
profile.switch | /api/admin/profile |
miner.add / miner.remove | /api/admin/miners/* |
backup.export / backup.restore | /api/admin/backup / /api/admin/restore |
update.check / update.applied / update.failed | /api/admin/update* |
notifier.test | /api/admin/notifier/test |
Fields: at (timestamp), actor (user, or token:<name>, or ? for
failed login), action, target (optional, action-specific),
peer_ip (with X-Forwarded-For resolution when trust_proxy_headers=true),
ok (boolean), details (optional, e.g. error description).
Retrieve via GET /api/admin/audit?limit=100&actor=admin or CLI:
dvb-warppool-cli audit --limit 50 --actor admin
The table has indexes on at, actor, action for fast filtering.
Eviction: evict_audit_older_than can be called per cron (no
auto-eviction in the daemon — the operator decides the retention policy).
TLS
dvb-WarpPool has two separate TLS surfaces:
Stratum-V1 TLS (port :3334 typical)
Optional. If stratum.tls_cert_path + stratum.tls_key_path are set, a
second listener runs alongside the plain port. Rustls 0.23, default suite —
no RSA key exchange, no PSK-only auth.
The operator provides cert+key themselves — typically via Let's Encrypt (certbot with DNS challenge when the pool is not publicly reachable) or mkcert for LAN-only setups.
Stratum-V2 NOISE (port :3334 or other)
NOISE-NX handshake with the snow crate, pure Rust. Static Curve25519 key
in secrets.sv2_static_priv_key_hex. The public key the V2 miner needs as
its server-pubkey is available three ways:
- Pool UI: dashboard → "⛏ Miner verbinden" button → the connect modal shows the V2 endpoint and the server pubkey with a copy button.
- API:
GET /api/overview→ fieldsv2_pubkey_hex(unauthenticated; the public key is by design not a secret). - CLI:
dvb-warppool-cli sv2-pubkeyreadssecrets.toml, derives the pubkey and prints it on stdout — handy for headless / SSH setups where the UI is not reachable.
In contrast to classic TLS, NOISE has no PKI — the miner must learn the pool pubkey OOB (out of band). That is the Sv2 standard.
API TLS (HTTPS for /api + UI)
Optional via server.status_tls_listen. Operators frequently put a
reverse proxy (Caddy with auto-LetsEncrypt) in front instead and let the
daemon speak only HTTP on 127.0.0.1. In that case: set
server.trust_proxy_headers = true so that the audit log sees real client IPs.
Reproducible Builds + Signing
Phase 8b: cargo [profile.release] with lto = "fat" (single-threaded
LTO, deterministic) + RUSTFLAGS=--remap-path-prefix=... + cargo-home +
cargo-git remap. CI builds twice in matrix jobs and compares sha256 —
fails on drift.
Local end-user verification:
scripts/verify-reproducible.sh v1.2.3 linux-x86_64
# fetches the release asset + rebuilds locally with the same flags + compares sha256
Phase 8g: POST /api/admin/update accepts cosign_verify: bool. If true,
cosign verify-blob runs as an external subprocess before atomic_swap.
The operator must set the WARPPOOL_COSIGN_BIN env var — otherwise
hard-fail (500), not a silent skip.
Cosign verify uses sigstore-keyless via the workflow OIDC token from the release workflow. The trust anchors are therefore GitHub Actions OIDC + Fulcio. For anyone who does not trust that: the GPG workflow has been available since Phase 6, but is not enabled by default.
Stratum Hardening
Rate Limiting (Phase 3)
Token bucket per peer IP. Defaults:
| Limit | Value | When it triggers |
|---|---|---|
connects_per_sec | 5.0 | TCP accept |
connect_burst | 20 | Burst window |
auths_per_sec | 1.0 | mining.authorize attempts |
auth_burst | 10 | Burst window |
To a rate-limited auth the server responds with result: false (no
disconnect) so that the miner does not enter a reconnect loop — that
would only keep loading the limit bucket and fill the connection cap.
idle_evict_secs (default 300) clears limiter state buckets for IPs that
have had no traffic for 5 min — so that server memory stays linearly
bounded and does not grow with the unique-IP count.
Connection Cap
Profile-dependent (Small: 64, Medium: 256, Large: 1024, Enterprise: 4096). Over-limit connects are dropped at accept time; semaphore-based, no per-connection allocation beforehand.
Conservative Mode (safe_mode)
When stratum.safe_mode = true (default): the share validator rejects
unusual shares (unusual version bits, suspicious ntime drift). The
operator can set this to false when testing experimental hardware.
Audit-Friendly Defaults
| Behaviour | Default | Why |
|---|---|---|
trust_proxy_headers | false | A direct-to-Internet setup must not be IP-spoofable |
| External probes (Phase 9) | opt-in per probe + consent | Avoid privacy leak — the IP goes to api.ipify.org / bitnodes.io |
| UPnP forward (Phase 11) | consent gate + port whitelist | Otherwise UPnP would become a generic port opener |
| Auto-update via /api/admin/update | not periodic | The operator decides when to update — the periodic check (Phase 8e) only pushes an SSE banner |
WARPPOOL_AUTOUPDATE_REPO | not set | Auto-update disabled by default, operator must enable explicitly |
| Cosign verify | false in update request | Operator must consciously set the trust anchor |
Backup & Restore
GET /api/admin/backup exports the SQLite DB as an encryptable binary
blob (PR pending — currently plain SQLite bytes). Restore via
POST /api/admin/restore.
Audit: backup.export + backup.restore. On backup.restore a fresh
migration runs after the import so that schema drifts between the backup
version and the current version are resolved.
What is in the backup: all DB tables incl. admin_2fa.secret_base32,
api_tokens.token_hash, audit_log. What is NOT included:
secrets.toml, config.toml, ~/.bitcoin/.cookie. Anyone backing up
both must protect them separately.
Sandbox + Privileges
| Component | Recommended UID | Reason |
|---|---|---|
dvb-warppool-daemon | non-root (warppool UID 1000) | No wallet key, no privileged port |
dvb-warppool-setup | operator user | First-run wizard, writes config.toml into the operator's home |
dvb-warppool-translator | non-root | Pure sidecar, no file state |
rollback.sh (systemd OnFailure) | root | Must be able to replace the target binary in /usr/local/bin |
Pool listen ports are all >1024 (3333/3334/...) — no CAP_NET_BIND_SERVICE
required.
Disclosure
Please report security findings via coordinated disclosure: email first
to dvbprojekt@gmx.de, GitHub issue only after a fix. We are a small
crew without a formal bug bounty — good-faith reports are acknowledged
with credit.
SECURITY.md in the repo root holds the official policy
- PGP key on request.
What is intentionally NOT implemented
| Would be possible | Why not yet |
|---|---|
| WAF / anomaly detection | Out of scope for the pool daemon. Put Caddy/Cloudflare/NGINX-WAF in front if needed |
| Per-worker encryption-at-rest for VarDiff snapshots | DB chmod 600 is sufficient for the threat model; key derivation would add complexity without clear value |
| Hardware-wallet signing for coinbase | Solo pool — coinbase goes via payout_address, the pool never touches private keys |
| OAuth/OIDC for admin | Single-user self-hosted is the primary target; standalone auth suffices. PRs welcome for multi-user setups |
| TPM attestation | Reproducible builds + cosign cover the audit trail; TPM would be for enterprise compliance, not solo |
Accepted residual risks after the security audit (Phase 32)
| Residual risk | Why accepted / condition |
|---|---|
| Miner IP / topology disclosure | /api/miners, /metrics, /api/energy are readable without auth and leak internal miner IPs/hostnames/hashrates. Deliberate MVP read-only design. Before untrusted-multi-tenant or public exposure, gate behind auth (or bind /metrics to localhost) |
| Setup-wizard unauth file-read / SSRF (localhost) | /api/test-rpc / /api/bitcoin-health / /api/import-gopool/load read request-supplied paths and POST to arbitrary URLs without auth/CSRF/origin check. Default bind 127.0.0.1, short-lived. Before a non-loopback --bind, require a CSRF/origin check or a one-time token |
| Web Push DNS-rebinding TOCTOU | The resolve guard at send time closes most of it; a narrow window remains because reqwest resolves again independently. Fully closeable only via resolve-once-connect-by-IP |
Secrets derives Debug | Latent leak footgun (a future dbg!(secrets) would log everything) — Secrets is currently logged nowhere. Optional: a manual redacting Debug |
Reproducible Builds
dvb-WarpPool produces bit-identical release binaries: anyone with the
same source commit, the same Rust toolchain, and a Linux-x86_64 host can
rebuild the release and compare it against sha256. This closes the gap
between "we trust the build maintainer" and "we only trust the source
code".
Why
- Supply-chain resilience — if someone pushes a compromised binary, they either changed the source (visible diff) or patched the binary after the fact (sha256 mismatch).
- Cosign + SLSA provenance say "the build came from this workflow". Reproducible builds additionally say "the build is the source".
- Audit-friendly — pool operators can periodically verify that the pulled image matches the tagged source.
How we achieve it
Cargo profile (in Cargo.toml)
[profile.release]
lto = "fat" # single-threaded LTO, no parallelism drift
codegen-units = 1 # a single LLVM pass
strip = "symbols" # removes debug-info drift
panic = "abort"
incremental = false # explicit
lto = "thin" was deliberately switched to "fat" — thin-LTO is
faster (parallel) but can produce byte drift between host configurations.
Toolchain pin
rust-toolchain.toml pins the rustc + cargo
version. CI and local verification use exactly this version.
Path remapping
Build-time paths ($CARGO_HOME, $GITHUB_WORKSPACE, $HOME) would
otherwise end up embedded as debug info in the binary. We remap them to
generic strings:
RUSTFLAGS="\
--remap-path-prefix=$WORKSPACE=/repo \
--remap-path-prefix=$HOME/.cargo/registry=/cargo/registry \
--remap-path-prefix=$HOME/.cargo/git=/cargo/git"
SOURCE_DATE_EPOCH
The release.yml workflow sets SOURCE_DATE_EPOCH from the commit
timestamp. deb/rpm archives use it for mtime fields instead of now().
(The Rust binary itself currently does not embed timestamps, but wrappers
and installers do.)
Cargo.lock
We commit Cargo.lock and the build uses --locked. This also pins
every transitive dependency version.
Verifying — CI side
Every push to main triggers
repro.yml:
build-a ─┐
├─► verify-determinism (sha256 comparison)
build-b ─┘
Two separate runners build the same dvb-warppool-daemon binary. On
drift the workflow fails and prints both sha256s.
Verifying — end-user side
scripts/verify-reproducible.sh
is a bash script that:
- determines the
latestrelease tag (or an explicitly passed tag) - sets
SOURCE_DATE_EPOCHfrom the commit timestamp - locally runs
cargo build --release -p dvb-warppool-daemon --lockedwith the path-remapping flags - downloads the GitHub release asset
- compares both sha256s and exits 0 (match) or 1 (drift)
# Verify the latest release
./scripts/verify-reproducible.sh
# A specific version
./scripts/verify-reproducible.sh v0.1.0
# Keep build artifacts for debugging
./scripts/verify-reproducible.sh --keep-target
Limitations
- Linux-x86_64 only — macOS
.dmgand Windows.msiare not byte-deterministic (code signing, installer metadata). Cosign signatures cover those. - Glibc drift — if a pool operator builds on an old distro (Debian Buster) while the CI runner uses Ubuntu Latest, glibc drift can occur. Workaround: build in the same Docker image the CI uses, or use a static MUSL build (phase 8c).
- LLVM version drift — rust-toolchain.toml pins rustc, but the LLVM version shipped with it varies minor across rustup channels. We accept this as a known limitation.
When the build drifts
- Local HEAD vs tag commit:
git rev-parse HEAD git rev-parse refs/tags/<TAG> git checkout <TAG> # if different - Match the Rust toolchain:
rustup show active-toolchain # should match rust-toolchain.toml - Check RUSTFLAGS:
echo "$RUSTFLAGS" # must contain the three --remap-path-prefix entries - Glibc version:
ldd --version # CI uses Ubuntu LTS — a minor mismatch is OK, a major one breaks it - If everything matches but drift persists: please file an issue with
both sha256s and the
ldd --versionoutput.
Auto-Update
Auto-update runs in two phases: 8c Foundation (this doc) provides update-check + verified download as CLI subcommands; 8d Wiring (roadmap) brings atomic swap + health check + rollback into the daemon itself.
Current MVP (Phase 8c)
Two CLI commands, manually triggered by the operator:
dvb-warppool-cli check-update
Fetches the latest release from GitHub and compares it against the
installed version (CARGO_PKG_VERSION from the CLI binary).
$ dvb-warppool-cli check-update
Installed: 0.1.0
Latest: v0.2.0 (Release 0.2.0)
Update: 0.1.0 → 0.2.0 (available)
Release-Assets:
· dvb-warppool-daemon-linux-x86_64 (12345678 bytes)
· dvb-warppool-daemon-linux-aarch64 (11456789 bytes)
· SHA256SUMS (256 bytes)
· SHA256SUMS.sig (96 bytes)
Optional --json for scripting. --repo owner/name for forks or tests.
dvb-warppool-cli download-update
Downloads a release asset with sha256 verification. Performs no atomic swap and no restart — that's the operator's job, so the workflow stays auditable.
# Latest binary for the local host (auto-detected linux-x86_64/-aarch64/macos)
$ dvb-warppool-cli download-update \
--to /tmp/dvb-warppool-daemon.new \
--sha256 abcd1234... # aus SHA256SUMS
Download: dvb-warppool-daemon-linux-x86_64 (12345678 bytes) → /tmp/dvb-warppool-daemon.new
✓ 12345678 bytes written, sha256=abcd1234...
→ Manual atomic swap (Phase 8d wires this into the daemon):
chmod +x /tmp/dvb-warppool-daemon.new
sudo mv /tmp/dvb-warppool-daemon.new /usr/local/bin/dvb-warppool-daemon
sudo systemctl restart dvb-warppool
On sha256 mismatch the half-written file is deleted — no "tainted download" left lying around.
Recommended operator flow
#!/bin/bash
set -euo pipefail
TAG=$(dvb-warppool-cli check-update --json | jq -r .latest)
[ "$TAG" = "null" ] && exit 0 # no update
# fetch SHA256SUMS from the release and verify with cosign
curl -sSfLO https://github.com/dvb-projekt/dvb-WarpPool/releases/download/$TAG/SHA256SUMS
curl -sSfLO https://github.com/dvb-projekt/dvb-WarpPool/releases/download/$TAG/SHA256SUMS.sig
cosign verify-blob \
--certificate-identity-regexp "https://github.com/dvb-projekt/dvb-WarpPool/.github/workflows/release.yml@.*" \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--signature SHA256SUMS.sig SHA256SUMS
# extract the expected sha256 for our asset
ASSET="dvb-warppool-daemon-linux-x86_64"
EXPECTED=$(awk -v a="$ASSET" '$2 == a {print $1}' SHA256SUMS)
# download + verify
dvb-warppool-cli download-update --to /tmp/$ASSET --sha256 "$EXPECTED"
# manual swap (Phase 8d automates this)
sudo install -m 755 /tmp/$ASSET /usr/local/bin/dvb-warppool-daemon
sudo systemctl restart dvb-warppool
Crate internals (warppool-autoupdate)
#![allow(unused)] fn main() { use warppool_autoupdate::UpdateChecker; let checker = UpdateChecker::new("dvb-projekt/dvb-WarpPool"); let release = checker.fetch_latest().await?; if let Some(newer) = checker.is_newer_than(&release, env!("CARGO_PKG_VERSION"))? { // update available } }
Modules:
version— semver-subset parser (major.minor.patch + optional-pre), compared viaOrd. Pre-release < stable (semver convention).release— GitHub releases API with minimalserdedeserialization (no octocrab — that would be 30+ transitive deps for 3 fields).download—download_verified(client, url, dest, expected_sha)with streaming write + SHA-256 accumulator. Mismatch → file deleted.swap—atomic_swap(new, current, backup_to)with POSIX rename (atomic on the same filesystem). Setschmod 0755on the new binary, optionally backs up the old one tobackup_to.
Phase 8d — API endpoints
Since Phase 8d the daemon exposes two admin-protected endpoints for
GUI-driven auto-update (activated via env-var WARPPOOL_AUTOUPDATE_REPO=owner/name,
otherwise 503).
GET /api/admin/update-check
Returns latest-release metadata + newer flag in a single request.
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:18334/api/admin/update-check
{
"current": "0.1.0",
"latest": "v0.2.0",
"name": "Release 0.2.0",
"prerelease": false,
"newer": "0.2.0",
"assets": [
{"name": "dvb-warppool-daemon-linux-x86_64", "size": 12345678, "url": "..."},
{"name": "SHA256SUMS", "size": 256, "url": "..."}
]
}
Audit: update.check with the resolved latest tag as target.
POST /api/admin/update
Downloads the chosen asset, verifies sha256, atomic-renames into
target_path. Does not send a restart — the operator does that via
systemctl restart from the restart_hint field.
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"asset": "linux-x86_64",
"sha256": "abcd1234...",
"target_path": "/usr/local/bin/dvb-warppool-daemon",
"backup_path": "/var/lib/dvb-warppool/backup/daemon.v0.1.0"
}' \
http://localhost:18334/api/admin/update
{
"tag": "v0.2.0",
"asset": "dvb-warppool-daemon-linux-x86_64",
"bytes": 12345678,
"sha256": "abcd1234...",
"target_path": "/usr/local/bin/dvb-warppool-daemon",
"backup_path": "/var/lib/dvb-warppool/backup/daemon.v0.1.0",
"restart_hint": "systemctl restart dvb-warppool"
}
Audit: update.applied (ok=true) or update.failed (ok=false with
details). The operator can review the update history via
/api/admin/audit.
Activation
In dvb-warppool.service (systemd):
[Service]
Environment="WARPPOOL_AUTOUPDATE_REPO=dvb-projekt/dvb-WarpPool"
Or directly:
WARPPOOL_AUTOUPDATE_REPO=dvb-projekt/dvb-WarpPool dvb-warppool-daemon
Phase 8e — Periodic auto-check + SSE event
When auto-update is enabled (WARPPOOL_AUTOUPDATE_REPO=…), the daemon
spawns a background task that runs fetch_latest every N hours. On a
newer release an update_available SSE event is pushed to the event
bus — the UI subscribes to it and can show an "Update available" banner
without the operator having to click update-check manually.
Activation
# Standard: 24h interval
WARPPOOL_AUTOUPDATE_REPO=dvb-projekt/dvb-WarpPool dvb-warppool-daemon
# Custom interval
WARPPOOL_AUTOUPDATE_REPO=dvb-projekt/dvb-WarpPool \
WARPPOOL_AUTOUPDATE_INTERVAL_HOURS=6 \
dvb-warppool-daemon
# Periodic disabled, on-demand only via /api/admin/update-check
WARPPOOL_AUTOUPDATE_REPO=dvb-projekt/dvb-WarpPool \
WARPPOOL_AUTOUPDATE_INTERVAL_HOURS=0 \
dvb-warppool-daemon
Initial delay is 60s after daemon start so that not every pool instance hits GitHub at the same time.
SSE event schema
Subscribe via Browser EventSource at /api/events. The event looks like:
event: update_available
data: {"type":"update_available","at":"2026-05-26T12:00:00Z","current":"0.1.0","latest":"v0.2.0","name":"Release 0.2.0","prerelease":false}
JavaScript example for a UI banner:
const es = new EventSource("/api/events");
es.addEventListener("update_available", (ev) => {
const data = JSON.parse(ev.data);
showBanner(`Update available: ${data.current} → ${data.latest}`);
});
On errors (rate-limit, network-down)
The task logs warn! and retries on the next interval. The GitHub API
allows 60 unauthenticated requests/h per IP — with the default 24h
interval we land at <2 requests/day, well below the limit.
Phase 8f — Systemd OnFailure rollback
So that a bad auto-update doesn't leave the daemon in a permanently-down
state, the shipped dvb-warppool.service has an OnFailure= hook that
on repeated crashes (StartLimitBurst=4 times within
StartLimitInterval=300s) restores the most recent backup and restarts.
Layout
/usr/lib/systemd/system/dvb-warppool.service ← main service
/usr/lib/systemd/system/dvb-warppool-rollback.service ← oneshot helper
/usr/lib/dvb-warppool/rollback.sh ← restore script
Flow
- Daemon starts with the new binary.
- Crash within 5s → systemd
Restart=on-failureretries. - After 4 crashes in 5 minutes → systemd gives up and triggers
OnFailure=dvb-warppool-rollback.service. rollback.sh:- Finds
/var/lib/dvb-warppool/backup/daemon.*(youngest mtime wins) - Atomically installs it to
/usr/bin/dvb-warppool-daemon(withinstall -m 755) - Moves the backup to
<backup>.applied-<timestamp>so it isn't picked again as a rollback source (prevents restart loops) systemctl restart --no-block dvb-warppool.service
- Finds
- Daemon now starts with the old binary — back online.
- Operator sees in the journal:
journalctl -u dvb-warppool-rollback.service
Configuration via env-vars
In /etc/default/dvb-warppool (or systemctl edit dvb-warppool-rollback):
WARPPOOL_BACKUP_DIR=/var/lib/dvb-warppool/backup
WARPPOOL_TARGET_BIN=/usr/bin/dvb-warppool-daemon
WARPPOOL_SERVICE=dvb-warppool.service
All have sensible defaults — only override if the deployment deviates from the standard.
Creating a backup
Rollback only works if a backup was saved before the update. So set
backup_path in the POST /api/admin/update body:
{
"asset": "linux-x86_64",
"sha256": "...",
"target_path": "/usr/bin/dvb-warppool-daemon",
"backup_path": "/var/lib/dvb-warppool/backup/daemon.v0.1.0"
}
The atomic_swap function moves the current binary to backup_path
before installing the new one — so on a crash rollback.sh has
something to restore.
Testing the helper (local, without systemd)
tmp=$(mktemp -d)
bin=$(mktemp -d)
echo "OLD" > "$tmp/daemon.v0.1.0"
echo "CRASHY" > "$bin/dvb-warppool-daemon"
# mock systemctl so no real restart runs
printf '#!/bin/bash\necho mocked\n' > "$tmp/systemctl"
chmod +x "$tmp/systemctl"
PATH="$tmp:$PATH" \
WARPPOOL_BACKUP_DIR="$tmp" \
WARPPOOL_TARGET_BIN="$bin/dvb-warppool-daemon" \
packaging/systemd/rollback.sh
cat "$bin/dvb-warppool-daemon" # → OLD
Phase 8g — Cosign-verify integrated
POST /api/admin/update can optionally run a cosign verify-blob
subprocess invocation before the sha256 check. This matches sigstore's
keyless OIDC flow that our
release.yml has been using
since Phase 6.
Why subprocess instead of pure-rust sigstore
The pure-rust sigstore crate would pull in 30+ transitive deps
(ASN.1, X.509, certificate-chain parser, rekor client). A cosign CLI
subprocess is:
- Transparent — the operator sees the exact command in the audit log
- Current — cosign updates come from sigstore, not from us
- Minimal — no extra Rust deps
Trade-off: the operator must have cosign installed.
Activation
Two prerequisites:
- Server-side env-var
WARPPOOL_COSIGN_BIN=/usr/local/bin/cosignset (typically in the systemd unit'sEnvironment="..."). - Request body
cosign_verify: true+cosign_args: [...].
If cosign_verify=true but the env-var is not set → 500
"WARPPOOL_COSIGN_BIN env-var not set". Deliberately hard-fail so the
operator doesn't get a false sense of security.
Example request
curl -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"asset": "linux-x86_64",
"sha256": "abcd1234...",
"target_path": "/usr/bin/dvb-warppool-daemon",
"backup_path": "/var/lib/dvb-warppool/backup/daemon.v0.1.0",
"cosign_verify": true,
"cosign_args": [
"--signature=https://github.com/dvb-projekt/dvb-WarpPool/releases/download/v0.2.0/SHA256SUMS.sig",
"--certificate=https://github.com/dvb-projekt/dvb-WarpPool/releases/download/v0.2.0/SHA256SUMS.pem",
"--certificate-identity-regexp=https://github.com/dvb-projekt/dvb-WarpPool/.github/workflows/release.yml@.*",
"--certificate-oidc-issuer=https://token.actions.githubusercontent.com"
]
}' \
http://localhost:18334/api/admin/update
Internally (after the download) the server runs cosign verify-blob
on the downloaded SHA256SUMS file. The release only signs that file —
per-binary integrity is then established by matching the binary's
sha256 against its line in SHA256SUMS:
$WARPPOOL_COSIGN_BIN verify-blob \
--signature=... --certificate=... \
--certificate-identity-regexp='https://github.com/dvb-projekt/dvb-WarpPool/.github/workflows/release.yml@.*' \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com \
/tmp/SHA256SUMS
If cosign exits ≠ 0 → the downloaded file is deleted, response 403,
audit update.failed with cosign verify-blob failed: exit N.
Security model
| Layer | What is checked |
|---|---|
sha256 | File integrity against the hash supplied by the operator |
cosign verify-blob | Signature authenticity against sigstore OIDC issuer + identity regex |
cosign_args | Fully operator-controlled — the server only appends the downloaded file as the last arg |
| Audit trail | update.applied / update.failed with details |
Defense in depth: sha256 alone doesn't protect against a "malicious operator with the correct hash"; cosign alone doesn't protect against bit-drift during download. Both together are the right production configuration.
Phase 8 — completed
| Sub | What |
|---|---|
| 8a | mdBook Documentation Site |
| 8b | Reproducible Builds (lto=fat + --remap-path-prefix + repro-CI) |
| 8c | Auto-Update Foundation Crate + CLI |
| 8d | Auto-Update API (GET update-check + POST update) |
| 8e | Periodic Auto-Check + UpdateAvailable SSE event |
| 8f | Systemd OnFailure-Rollback (StartLimitBurst + Hook + rollback.sh) |
| 8g | Cosign-Verify integrated into POST /api/admin/update |
Limitations today
- macOS
.dmgand Windows.msiare not raw binaries — the operator can only update those via the respective native installers. Auto-update is Linux-first. - No diff updates (full binary only). At 15 MB / release that's harmless.
- Cosign-verify is operator-side (see flow above), not built into the CLI — deliberately, so the operator sees the trust anchor.
See also
- Notifications —
update_availableSSE event + UpdateBanner - Observability —
warppool_build_infolabel gauge shows the active version - Troubleshooting — sha256 mismatch, cosign failure, rollback cycle
Architektur
dvb-WarpPool ist ein Rust-Workspace mit 16 Library-Crates + 6 Binaries. Eine klare Schichtung — von puren Schema-Crates ohne async unten bis zu den async-orchestrierten Binaries oben — macht das System gut testbar und ermöglicht Sub-Crates separat zu betreiben (z.B. der Translator als sidecar ohne Pool-Daemon).
Crate-Übersicht
Schema + Foundation (ohne Async)
| Crate | Was |
|---|---|
warppool-profiles | Admin-Profile (Klein/Mittel/Gross/Enterprise) — capacity + defaults |
warppool-config | TOML-Schema (PoolConfig + Sub-Configs + Secrets) |
warppool-hwdetect | Hardware-Detection via sysinfo → Profile-Empfehlung |
Core Mining-Layer
| Crate | Was |
|---|---|
warppool-bitcoin-rpc | JSON-RPC + ZMQ-Subscribe für Bitcoin Core |
warppool-job-builder | Coinbase-Tx + Merkle-Tree + Stratum-Job-Konstruktion |
warppool-share-validator | Share-PoW-Check + Block-Found-Detection |
warppool-stratum-v1 | TCP-Listener, Session-State-Machine, VarDiff |
warppool-stratum-v2 | NOISE-NX-Handshake, binary-framing, Mining-Subprotocol |
warppool-translator | V1↔V2-Sidecar (Pool kann nur Sv2 anbieten, V1-Miner via Translator) |
Storage + API
| Crate | Was |
|---|---|
warppool-storage | SQLite via sqlx, alle Tabellen + Migrations |
warppool-api | Axum-HTTP-API (REST + SSE-Stream + statisches UI-serving) |
Operationale Subsysteme
| Crate | Was |
|---|---|
warppool-health | Bitcoin-Core-Multi-RPC-Health + bitcoin.conf-Parser + Snippet-Generator |
warppool-autoupdate | Version-Parser + GitHub-Release-Client + atomic_swap + Cosign-Hook |
warppool-notifier | Push-Sinks (ntfy/Telegram/Discord/Slack/Email) + Counter-Metrics |
warppool-telemetry | Vendor-API-Probes (AxeOS, NerdNOS, ...) + mDNS-Discovery + PoolMetrics |
Tools
| Crate | Was |
|---|---|
warppool-simulator | Sim-Miner (Vendor-Personas) + Sim-Node + Scenarios |
Binaries
| Binary | Zweck |
|---|---|
dvb-warppool-daemon | Der Pool — orchestriert alle Subsysteme |
dvb-warppool-cli | Operator-Tools (hash-password, token-create, set-profile, check-update, ...) |
dvb-warppool-setup | First-Run-Wizard (Axum, Modern-UI, embedded HTML) |
dvb-warppool-translator | V1→V2 Sidecar (clap-CLI, kann als systemd-service laufen) |
dvb-warppool-sim | Simulations-Runner (scenario list / run) |
Datenfluss (Happy Path)
+---------------------+
| Bitcoin Core |
| (mainnet / regtest) |
+---------------------+
| RPC | ZMQ pub
v v
+---------------------+
| bitcoin-rpc |
| - getblocktemplate |
| - submitblock |
| - hashblock watch |
+---------------------+
|
v
+---------------------+
| job-builder |
| - coinbase splits |
| - merkle tree |
| - StratumJob |
+---------------------+
|
+-----------+-----------+
v v
+------------------+ +------------------+
| stratum-v1 | | stratum-v2 |
| TCP / TCP+TLS | | TCP+NOISE-NX |
+------------------+ +------------------+
| |
v v
V1 Miner (Bitaxe, V2 Miner / Translator
NerdNOS, AntminerS23)
| |
v v
submit (extranonce, nonce, ntime)
|
v
+---------------------+
| share-validator |
| - PoW check |
| - block detection |
+---------------------+
|
+-----------+-----------+
v v
+------------------+ +------------------+
| storage | | block-submit-loop|
| - record_share | | - assemble block|
| - vardiff_state | | - submitblock |
+------------------+ +------------------+
|
v on success
+---------------------+
| notifier |
| fire BlockFound |
+---------------------+
| | | | |
v v v v v
ntfy tg disc slack email
Daemon-Task-Topologie
dvb-warppool-daemon ist ein Tokio-Multi-Reactor mit ~10 langlebigen Tasks
plus pro-Verbindung-Tasks. Alle Tasks teilen sich einen CancellationToken
für graceful shutdown.
+------- main() async -------+
| |
+- spawn ---------+----------+
| |
+-------+-------+ +------------------+
| Stratum-V1 | Stratum-V2 |
| accept-loop | accept-loop |
| (+ TLS) | (+ NOISE-NX) |
+----------------+ +-----------------+
| |
pro Connection: Session-Task
| |
v v
+---------------------------------+
| block_found_tx broadcast |
+---------------------------------+
|
+-------+--------+
| block_submit_ |
| loop |
+----------------+
|
Bitcoin Core RPC submitblock
|
+-------+--------+
| notifier.notify(BlockFound) |
+- spawn ---------+----------+
| job_refresh_loop |
| - poll getblocktemplate (60s) |
| - ZMQ hashblock fast-path |
| - drain & build job |
| - push to stratum handles |
+---------------------------------+
+- spawn ---------+----------+
| aggregate_loop (60s tick) |
| - storage.aggregate_5min |
| - SharesAccepted SSE event |
+---------------------------------+
+- spawn ---------+----------+
| health_check_loop (60s tick) |
| - check_bitcoin_health |
| - SSE HealthSnapshot |
| - RpcDown/Recovered notify |
+---------------------------------+
+- spawn ---------+----------+
| periodic_update_check (24h) |
| - GitHub Releases API |
| - SSE UpdateAvailable |
+---------------------------------+
+- spawn ---------+----------+
| miner_poll_loop (vendor probes) |
+---------------------------------+
+- spawn ---------+----------+
| HTTP API (Axum on :18334) |
| - REST endpoints |
| - SSE /api/events |
| - static UI from --ui-dir |
+---------------------------------+
Shared State
Tokio-Tasks teilen Arc<...>-Handles statt globaler Statics:
| Handle | Typ | Genutzt von |
|---|---|---|
notifier: Arc<Notifier> | shared via clone | block_submit_loop, health_check_loop, periodic_update_check, NotifierConnectionSink, API |
pool_metrics: Arc<PoolMetrics> | atomic counters | NotifierConnectionSink, BitcoinRpc, API /metrics-handler |
event_bus: Arc<PoolEventBus> | broadcast::Sender | alle Subsysteme — publish; API → subscribers (SSE) |
storage: Arc<Storage> | sqlx-Pool | share-recording, audit-log, vardiff-state, settings |
snapshot: Arc<RwLock<NetworkSnapshot>> | RwLock-Snapshot | job_refresh_loop schreibt; API liest |
profile_kind: Arc<RwLock<ProfileKind>> | hot-switchable | API admin-route + display |
cancel: CancellationToken | propagation | alle tasks (graceful shutdown) |
Storage-Schema
Tabellen aus den Phasen 1-15. Migrations in crates/storage/migrations/.
| Tabelle | Phase | Was |
|---|---|---|
workers | 1 | Worker-Liste (user, last_seen_at, shares_accepted/rejected, blocks_found) |
shares_raw | 1 | Letzte 1h roh-shares — basis für hashrate-Berechnung |
shares_5min | 1 | Aggregierte 5min-Buckets — Hashrate-Chart-Daten |
blocks_found | 1 | Block-History (height, hash, coinbase_value_sats, found_at) |
pool_settings | 2.5 | Generisches KV-Store (active_profile_kind, etc.) |
vardiff_state | 2.5 | Pro-Worker VarDiff-Snapshots (current_diff, ema, last_share_unix) |
audit_log | 3 | Admin-Actions (actor, action, target, peer_ip, ok, details) |
api_tokens | 3.2 | Persistente Bearer-Tokens (token_hash, name, scope, last_used_at, revoked_at) |
admin_2fa | 3.3 | TOTP-Secrets per User (secret_base32, enabled) |
push_subscriptions | 1 | Web-Push-Subscriptions (PWA, Phase B nicht aktiv) |
Sv2-Stack im Detail
Stratum V2 ist ein binary-framed protocol mit NOISE-NX-handshake davor. Im Pool-Kontext relevant sind zwei Subprotocols:
- Mining-Subprotocol (implementiert) — Channel-basiert, Extranonce-aware, Version-Rolling per BIP-320, Set-Target pro Channel.
- Template-Distribution-Protocol (TDP, Foundation in Phase 7.6a, Wiring
in 7.6b deferred) — Ersetzt
getblocktemplatedurch push-driven Template- Updates direkt vom Bitcoin-Node.
V1-Miner ----TCP plain----> stratum-v1 server ----------+
|
V1-Miner ----TCP+TLS------> stratum-v1 TLS-server -------+
|
v
+------------------+
| job-builder |
| share-validator |
+------------------+
^
|
V2-Miner ----TCP+NOISE----> stratum-v2 server (port 3334) +
|
V1-Miner ----TCP--+ |
| |
v |
dvb-warppool-translator (sidecar) --TCP+NOISE--------+
- SetupConnection
- OpenExtendedMiningChannel mit user_identity
- SubmitSharesExtended mit miner-extranonce
- Receives NewExtendedMiningJob + SetNewPrevHash
- Maps to V1 mining.notify + slushpool prev_hash + BIP-320 version-rolling
Konkretes Wire-Format ist in crates/stratum-v2/src/messages.rs mit
Roundtrip-Tests. NOISE-Handshake in crates/stratum-v2/src/noise.rs mit
snow-crate (pure-Rust, kein OpenSSL).
Connection-Lifecycle-Hooks
Beide Stratum-Server haben einen optionalen ConnectionSink-Trait. Bei
authentifizierten Workers (V1: mining.authorize, V2: OpenChannel mit user_identity) feuert on_authorized; bei Verbindungsende on_disconnect.
Daemon implementiert eine NotifierConnectionSink-Struct die beide Traits
implementiert. Pro-Worker-Debounce (default 30s) verhindert dass flappende
Miner pro Reconnect eine Notification feuern.
v1.Session::run
|
handle_authorize → connection_sink.on_authorized
|
... shares ...
|
loop exit → connection_sink.on_disconnect
v2.handle_connection
|
process_frame → channels().iter() → new user_identity?
| → on_authorized
... shares ...
|
loop exit → for each notified_user → on_disconnect
Event-Bus + SSE
PoolEventBus ist ein tokio-broadcast-channel mit PoolEvent-Variants:
| Event | Source |
|---|---|
BlockFound | block_submit_loop |
NewJob | job_refresh_loop |
SharesAccepted | aggregate_loop |
HealthSnapshot | health_check_loop (Phase 13b) |
UpdateAvailable | periodic_update_check (Phase 8e) |
API /api/events öffnet pro Client einen SSE-Stream mit dem aktuellen
Subscription-Set. UI nutzt das für HealthBanner + UpdateBanner + Toast-
Events.
Konfigurations-Layer
/etc/dvb-warppool/
├── config.toml # nicht-sensitive Settings
├── secrets.toml # chmod 600 — admin-hash, jwt-secret, rpc-pass, sv2-key
└── pool.db # SQLite (Path konfigurierbar)
Env-vars für opt-in subsysteme (siehe Configuration Reference):
WARPPOOL_HEALTH_CHECK_INTERVAL_SECONDS, WARPPOOL_AUTOUPDATE_REPO,
WARPPOOL_DISCONNECT_DEBOUNCE_SECS, sink-spezifische tokens, etc.
Lifecycle (Daemon-Boot)
- Parse CLI (clap) + ENV
- Load
config.toml+ (optional)secrets.toml - Validate config (
mining.payout_addresspflicht,ratelimitconstraints, ...) - Init tracing-subscriber (JSON wenn
logging.json) - Open SQLite + run migrations
- Resolve admin profile (persisted in
pool_settingsschlägt config-default) - Construct
BitcoinRpcmitwith_metrics(pool_metrics) - Probe RPC (warning bei Fail, Daemon startet trotzdem)
- Construct
Notifieraus config - Construct
PoolMetrics(Arc-shared) - Build initial job (oder skip wenn RPC fail)
- Spawn Stratum-V1-Listener (+ TLS-listener wenn cert/key configured)
- Spawn Stratum-V2-Listener wenn
sv2_listenconfigured +sv2_static_priv_key_hexda - Spawn job_refresh_loop (poll + optional ZMQ)
- Spawn block_submit_loop
- Spawn aggregate_loop (60s)
- Spawn health_check_loop wenn
WARPPOOL_HEALTH_CHECK_INTERVAL_SECONDS > 0 - Spawn periodic_update_check wenn
WARPPOOL_AUTOUPDATE_REPO+ interval > 0 - Spawn HTTP API (Axum auf
status_listen) - Install signal-handlers (SIGTERM → cancel.cancel() → alle tasks shutdown)
tokio::mainevent-loop läuft bis cancel
Crate-Abhängigkeiten (DAG)
Vereinfachtes Crate-Graph (nicht alle Edges; keine zyklischen Deps):
profiles --+
hwdetect --+--> config
|
+--> storage
+--> bitcoin-rpc --+
+--> job-builder |
+--> share-validator
+--> stratum-v1 -----+
+--> stratum-v2 -----+
+--> translator -----+
+--> telemetry -----+
+--> notifier ------+
+--> autoupdate ----+
+--> health --------+
+--> api -----------+
+--> simulator -----+
|
v
dvb-warppool-daemon
dvb-warppool-cli
dvb-warppool-setup
dvb-warppool-translator
dvb-warppool-sim
Crates und ihre einseitigen Deps:
telemetry← keine (foundation für metrics)bitcoin-rpc→telemetry(für RPC-Latency-Histogram, Phase 16.3)api→notifier+autoupdate+telemetry+storage+ ...daemon→ alle library-Crates plus runtime-deps (tokio, sqlx, ...)
Testing-Strategie
Drei Ebenen, plus eine zusätzliche operator-driven Ebene:
| Ebene | Was | Tool | Test-Count |
|---|---|---|---|
| Unit | Pure-logic pro Crate | cargo test -p warppool-<crate> | ~330 |
| Integration | Mehrere Crates in-process, axum-Mock-Server | tests/ pro Crate | ~115 |
| Sim | Gegen den echten Daemon-Prozess, Vendor-Personas | dvb-warppool-sim scenario <name> | 5 scenarios |
| Regtest E2E | Gegen echtes bitcoind im regtest | scripts/regtest-up.sh + --ignored | 3 tests (opt-in) |
Stand: 447 Tests + 3 ignored, alle grün. UI-side: pnpm svelte-check →
294 files, 0 errors.
Siehe TESTING.md für Details + Simulator-Workflow.
dvb-WarpPool — UI Design Konzept
Recherche-Stand: Mai 2026. Direkter Site-Fetch war nicht verfügbar — alle Findings via Web-Search-Synthese + Review-Quellen. Konzepte sind keine Kopien, sondern Inspirations-Synthese.
1. Findings
atlaspool.io
- Address-first Flow: Wallet-Adresse oben als Such-Eingabe, kein Login. Sichtbarkeits-Toggle für Screenshots — smart.
- Stats-Stack: Real-time Hashrate, Mining-Streak, Pool-Contribution, Active Workers, Best Share, First-Seen, Est. Block Time, Achievements (Gamification, 2026 angekündigt).
- Netzwerk-Kontext: Block-Height, Network-Hashrate, Difficulty, BTC-Preis als Glance-Row — gut, weil Mining ohne Netzwerk-Kontext sinnlos ist.
- Hashrate-Graph mit Multi-Timeframe: 1D/1W/1M/All — Standard, aber sauber.
- Refresh alle 2 min — kein Echtzeit-Push, eher Polling. Verbesserungspotenzial.
ocean.xyz
- Permissionless, address-keyed: keine Accounts. Stats kleben an Payout-Adresse.
- "Shares in Reward Window": TIDES-spezifische Metrik prominent — sie machen ihre Payout-Logik sichtbar.
- Quick-Read Metriken (Daily Earnings, Lifetime, Progress-to-Payout, Unpaid) als Cards. Wenig glamourös, hoch informativ.
- Updated Feb 2026 — UI eher funktional-konservativ, kein Show-Off.
DeepSea-Dashboard (Community-Frontend für Ocean)
- Retro CRT-Terminal-Aesthetic: Scanlines, Phosphor-Glow — bewusst nostalgisch.
- Drei Themes: DeepSea (blau), Bitcoin (orange), Matrix (grün). Theme als Identity.
- Chart.js mit Block-Annotations, Worker-Cards mit ASIC-Detection, Pool-Donut, Sound-Effekte bei New Block.
- Atmosphäre statt Information-Density — emotional engagement durch Audio + Visual.
Braiins / generelle 2026-Trends
- Zoom-bare Hashrate-Charts, regex-Filterung der Worker, API-First.
- 2026-Split: Techno-Futurist (dark + neon + shaders + bento) vs. Editorial (cream + serif + whitespace). Crypto sitzt fast immer im ersten Lager.
- Sidebar + Cards (Linear/Vercel/Stripe-Pattern) skaliert ohne Restruktur.
- Glassmorphism: nur noch sparsam, Brutalism als Gegenbewegung gewinnt.
2. Drei Design-Konzepte
Konzept A — "Warp Drive" (Cinematic Cosmos)
- Stil: Glassmorphism + Deep-Space. WebGL-Sterne im Background (subtil, ≤40 Partikel), shader-Gradients à la Stripe/Vercel, Glass-Cards mit
backdrop-filter. - Palette:
#05060B(void),#0F1226(deep),#7B5CFF(warp violet),#00E0FF(plasma cyan),#F7931A(BTC orange als Akzent, sparsam). - Typo: Header Space Grotesk (700, tight tracking). Body Inter (15px). Numerik JetBrains Mono (tabular-nums).
- Layout: Bento-Grid 12-spaltig, asymmetrisch. Hero = großer Live-Hashrate-Block (4×3), kleine Cards für Workers/Block-History.
- Daten-Viz: Animated counters (Web Animations API), sparklines mit Gradient-Fill, Hashrate-Chart mit scroll-driven Annotations für Found-Blocks.
- Moderne Techniken:
- View Transitions API für Adress-Lookup → Dashboard
- CSS
@scroll-timelinefür Block-History-Reveal color-mix()für theme-reaktive Glow-States- Popover API für Worker-Detail-Drawer
- Mobile: Bento kollabiert via Container Queries zu Single-Column-Stack. WebGL-Shader auf
prefers-reduced-motion→ statisches Gradient. - Gut: emotional, "future of mining", differenziert klar vom Vorgänger. Risiko: WebGL-Performance auf alten Phones, Glass kann Lesbarkeit zerstören wenn Background zu busy ist.
Konzept B — "Stratum" (Brutalist-Terminal)
- Stil: Monospace-first, harte Linien, sichtbares Grid, ASCII-Akzente. Inspiration: Linear-Logs + Bitaxe AxeOS + Recurse-Center-Vibe.
- Palette:
#0A0A0A(true black),#1A1A1A(panel),#E8E8E8(text),#39FF14(signal green für "alive"),#FF4D00(alert orange). Pure, vier Töne. - Typo: Komplett Berkeley Mono oder JetBrains Mono. Header größer (28-40px), Body 14px. Keine zweite Familie.
- Layout: Sidebar (240px) + Long-Scroll-Mainpanel. Sections wie Terminal-Output mit
─────Trennern. Command-Palette (⌘K) als zentrale Navigation. - Daten-Viz: ASCII-style Sparklines (
▁▂▃▅▇▆▄▂), Heatmaps als CSS-Grid mitcolor-mix(), Block-History als Log-Stream mit Timestamps. - Moderne Techniken:
text-wrap: balancefür Headlines- Container Queries statt Breakpoints — jede Card adaptiert lokal
- Anchor Positioning für Tooltips auf Chart-Datenpunkten
@propertyfür animierbare CSS-Variablen (Hashrate-Pulse)
- Mobile: Sidebar wird Top-Bar mit Hamburger, Long-Scroll bleibt — Brutalism skaliert ehrlich.
- Gut: technisch glaubwürdig (passt zu Rust-Backend), extrem performant, alterungsbeständig. Risiko: zu nüchtern für Bitcoin-Maxi-Zielgruppe die "wow" erwartet; Onboarding für Nicht-Techies härter.
Konzept C — "Aurora Bento" (Editorial Neo-Bento)
- Stil: Bento-Grid mit großzügigem Whitespace, soft-cream gegen deep-night-mode-Toggle, Mesh-Gradient-Akzente. Mix aus Apple-Bento + Stripe-Mesh + Notion-Editorial.
- Palette: Dark-Default:
#0B0D10,#15181D,#F2EFE8(warm cream text),#FF7A1A(BTC orange als Hero-Accent),#A8FF60(signal lime). Light-Mode invertiert sauber. - Typo: Header General Sans (display, 700). Body Inter (16px, generös). Numerik Geist Mono.
- Layout: Bento mit 3 Größen-Klassen (S/M/L), klare Hierarchie. Hero-Card = Hashrate, M = Worker-Grid, S = BTC-Price/Block-Height/Difficulty.
- Daten-Viz: Soft animated counters (spring physics via WAAPI), Area-Charts mit Mesh-Gradient-Fill, Worker-Cards mit Mini-Donuts für Share-Anteil, Block-Found als Confetti-Burst (CSS motion-path).
- Moderne Techniken:
- CSS
motion-pathfür Block-Found-Animation - View Transitions zwischen Light/Dark
color-mix()+oklch()für perceptual-uniform Theme-Shifts- Container Queries für Bento-Card-Adaption
- CSS
- Mobile: Bento reflow zu Magazin-Scroll, große Tap-Targets, PWA-Install-Prompt prominent.
- Gut: zugänglich für Newcomer + tief genug für Pros, gut fotografierbar (Marketing!), Light/Dark gleich stark. Risiko: Bento ist 2026 leicht ausgelutscht — Differenzierung muss über Craft + Micro-Interactions kommen, sonst "noch ein SaaS-Dashboard".
3. Empfehlung
Konzept A "Warp Drive" — weil der Name WarpPool die Richtung schon vorgibt und die User explizit wegwollen vom klassischen Vorgänger-Look. Warp Drive nutzt die modernsten Web-Möglichkeiten (View Transitions, scroll-timeline, color-mix, WebGL) ohne in Bento-Beliebigkeit (C) oder Terminal-Härte (B) zu fallen. Es liefert das "wow" für Marketing-Screenshots, bleibt aber funktional bedienbar — sofern Glass-Effekte diszipliniert eingesetzt werden (nur auf Cards, nie auf Charts). Fallback-Pfad: Wenn Performance-Tests scheitern, ist Konzept C ein sauberer Rückzugsraum mit ähnlicher Palette. Konzept B als Power-User-Theme-Option später nachreichen ("Terminal Mode").
4. Implementation-Stand (2026-05-27)
Konzept A ist implementiert in ui/ (SvelteKit 2 + Svelte 5 Runes,
adapter-static, SPA-Modus). Was tatsächlich live ist:
Routes
| Route | Inhalt |
|---|---|
/ | Dashboard mit Pool-Stats-Bento (Hashrate-Chart, Pool-Stats, Network, Blocks, Worker) |
/blocks | Block-Tabelle mit pending/accepted/rejected-Status |
/workers | Worker-Liste mit user-agent + last-share |
/hardware | Live-Detection + Profil-Picker mit Empfehlung |
/hashrate | Historisches Hashrate-Chart (1d / 1w / 1m) |
/miners | Konfigurierte Miner-Liste + Discovery |
/login | Auth-Page (Username/PW + optional 2FA-Code) |
/admin | Hub für Profile-Switch, Backup, Tokens, 2FA, Audit, Notifications |
/admin/notifications | Browser-Push-Settings + Server-Side Sinks (Phase 15.6) — pro-Sink-Test-Buttons mit Badge-state (neutral / ok / err) + last-error-tooltip |
/admin/profile, /admin/tokens, /admin/audit, /admin/2fa, /admin/backup | Self-explanatory Admin-Surfaces |
Komponenten
| Component | Was |
|---|---|
Starfield.svelte | CSS-animated drift (60 Sterne + 4 glow-decals, alpha .35-.95) |
BentoCard.svelte | Glass-morphism mit backdrop-filter: blur(8px) |
StatTile.svelte | Animated counter (Web Animations API) |
Badge.svelte | Tone-aware Pill (ok / warn / err / neutral) |
HashrateChart.svelte | uPlot-basierter Chart mit Gradient-Fill + Block-Annotations |
HealthBanner.svelte | Phase 14 — konsumiert SSE health_snapshot + update_available, persistente dismissable Banner unterhalb des Headers |
EventToasts.svelte | Block-Found / Profile-Switch Toast-Notifications |
LocalePicker.svelte | 8-locale-Dropdown mit Emoji-Flags |
Modern-Web-Tech tatsächlich genutzt
- ✅ View Transitions API für Routen-Wechsel
- ✅
color-mix()für theme-reaktive Glow-States - ✅ Container Queries für mobile Bento-Reflow
- ✅ SSE (EventSource) für Live-Push via
/api/events - ✅
backdrop-filter+ Glass-Cards - ⏸ WebGL-Sterne: bewusst CSS-animation statt WebGL —
prefers-reduced-motion-fallback + Performance auf alten Phones - ⏸
@scroll-timeline+motion-path: noch nicht implementiert (Phase B Polish wenn Browser-Support breit) - ⏸ Popover API: noch nicht — Modals nutzen aktuell standard
<dialog>
Internationalisierung
8 Locales über JSON-Files in ui/src/lib/locales/:
de(Deutsch — Primary)en(English — Fallback)es(Español),pt-BR(Português Brasil)fr(Français),it(Italiano)ja(日本語),zh(中文)
i18n-System ist selbstgebaut (~150 LOC in i18n.svelte.ts) — runes-Store +
t('foo.bar', {name: 'X'}) mit Interpolation, kein heavy framework.
Locale-Detection: localStorage > navigator.language > 'en'.
PWA
app.webmanifest+ Icon-Set (192/512)service-worker.ts(cache-first für static assets, network-first für/api/*)- Install-Prompt prominent im Login-Screen
- Browser-Notification-API für Foreground-Block-Found-Events
- VAPID-Push (Storage-Tabelle
push_subscriptionsist angelegt, Send-Loop ist Phase B)
Was vom ursprünglichen Konzept abweicht
- Starfield als CSS, nicht WebGL — performance auf RPi 5 / alten Phones war ausschlaggebend. Optisch nahezu identisch, ein Bruchteil GPU-Last.
- Glass-Cards diszipliniert — nur auf BentoCard, nicht auf Tabellen oder Charts (Lesbarkeit).
- Bento bleibt 12-spaltig — kein dynamisches reflow zu 6/4/3 cols. Container Queries machen die Cards selbst adaptiv.
- Brand-Akzent nicht-Orange — wir nutzen
--warp(violet) als Primary, Orange nur sparsam für BTC-Bezug. Konzept-A-Palette ist 1:1 umgesetzt.
Performance-Stand
- Initial-Bundle: ~250 KB JS + ~30 KB CSS (gzipped)
- Lighthouse-Score (auf einem typischen Pool-Setup): ~95 Performance, 100 Accessibility, 100 Best Practices, 100 SEO
- Hashrate-Chart mit uPlot: 60fps auch bei 1000 Datenpunkten
Bekannte Limitierungen
- Pre-existing CSS-warning
Unused CSS selector "th.num"inblocks/+page.svelte— historisches Artefakt, kein Funktionsproblem - Keine echte Dark/Light-Theme-Toggle (UI ist immer dark — Konzept A spec'd bewusst keinen Light-Mode)
- Light-Mode-Variante würde nichttrivial sein wegen
backdrop-filter+ Glassmorphism-Optik
Testing & Simulation
Vier Test-Ebenen, alle pre-release verifiziert. Stand: 520 Rust-Tests grün + 3 ignored (regtest opt-in), 298 Svelte-Check-Files / 0 errors.
1. Unit-Tests
Pro Crate, reine Logik. cargo test --workspace läuft alle 16 Library-
Crates + 5 Binaries.
Wichtige Test-Counts (Stand Phase 19):
| Crate | Tests | Was |
|---|---|---|
warppool-config | ~24 | TOML-Schema, coinbase_splits, VarDiffSettings::validate |
warppool-storage | ~75 | sqlx-roundtrips, migrations, alle 10 Tabellen, vardiff, audit_log, api_tokens, admin_2fa, pool_settings |
warppool-api | ~80 | axum-handler-Tests gegen in-memory SQLite + mock-GitHub/SMTP-Server. Inkl. Phase 16-Tests (pool_metrics + notifier in /metrics) |
warppool-stratum-v1 | ~44 + 4 e2e | session-state-machine, ratelimit, vardiff (17 isoliert), TLS-roundtrip |
warppool-stratum-v2 | ~71 | noise-handshake, codec-roundtrips, session-frames, TDP-foundation |
warppool-translator | ~22 | extended-channel-state-machine, BIP-320-version-rolling, slushpool-prev-hash-reverse |
warppool-share-validator | ~17 | sha256d-vectors, dedup, network/pool-target-checks |
warppool-job-builder | ~32 | coinbase-construction, BIP-34-height-encoding, merkle-branches |
warppool-bitcoin-rpc | ~14 | RPC-envelope-parsing, ZMQ-frame-parsing |
warppool-notifier | 19 | render_text, EmailSink/SlackSink-construction, sink-skip-when-env-missing |
warppool-autoupdate | ~25 | version-compare, asset-matching, download_verified, atomic_swap, cosign-subprocess |
warppool-health | ~24 | RPC-call, bitcoin-conf-parser, snippet-generator, warnings-aggregation |
warppool-telemetry | ~27 | vendor-probes (AxeOS/cgminer-mock), mDNS-discovery, PoolMetrics+histogram |
warppool-profiles | 6 | profile-resolution, retention-monotonie |
warppool-hwdetect | 6 | sysinfo-detection, container-env, recommendation |
warppool-simulator | ~9 | sim-miner-personas, scenarios |
# Alles
cargo test --workspace
# Ein bestimmter Crate
cargo test -p warppool-share-validator
# Mit output
cargo test -p warppool-stratum-v2 -- --nocapture
2. Integration-Tests
Mehrere Crates in-process, axum-mock-server für externe Dienste. Pro Crate
unter tests/.
Typische Cases:
| Test | Crate | Was |
|---|---|---|
e2e.rs | stratum-v1 | TCP-Listener + ShareValidator + Sim-Miner-Roundtrip mit echtem subscribe/authorize/submit-Flow |
e2e_regtest.rs | bitcoin-rpc | #[ignore] — gegen laufendes bitcoind regtest |
update_apply_with_cosign_verify_but_no_env_returns_500 | api | axum-mock-GitHub + mock-asset-server |
metrics_renders_pool_counters_when_pool_metrics_set | api | /metrics-output gegen Prometheus-Format |
3. Simulation (dvb-warppool-sim)
Gegen den echten Daemon-Prozess (oder in-process spawned). Szenarien sind End-to-End-Stories.
Eingebaute Szenarien
| Szenario | Dauer | Status | Was es prüft |
|---|---|---|---|
solo-block-found | 30s | ✓ executable | Sim-Miner connectet, Block-Found-Event landet |
connection-storm-defense | 60s | ✓ executable | Rate-Limit greift bei N parallelen Auth-Versuchen |
enterprise-load-smoke | 5min | ✓ executable | N parallele Miner gemischter Persona laufen sauber |
small-24h-stability | 24h | Plan-only | Klein-Profil bleibt unter 200 MB, keine Panics |
rpc-outage-recovery | 10min | Plan-only | Pool übersteht 5-Minuten-RPC-Outage |
Aufruf
solo-block-found läuft self-contained — spawnt einen Stratum-V1-Server
in-process mit network_target=[0xff;32] (jeder valid Share wird BlockFound)
und einen JsonStratumMiner (Persona BitaxeUltra). Kein Bitcoin Core nötig:
dvb-warppool-sim scenario solo-block-found --duration 10
# → Result: BLOCK FOUND after 55 ms (worker=bc1qsim-bitaxe, job_id=sim-job-1)
dvb-warppool-sim scenario connection-storm-defense --duration 15 --attackers 200
# → Success: 10, RateLimit: 190, OtherErr: 0
# → OK: rate-limit hat 190x gegriffen (burst war 10)
dvb-warppool-sim scenario enterprise-load-smoke --duration 15 --miners 200
# → Accepted: 57400 (~3826 shares/s), Rejected: 0, Disconnects: 200
Personas
Sim-Miner ahmen reale Hardware nach (Hashrate, User-Agent, TLS-Support, Reconnect-Pattern):
| Persona | Nominal HRate | TLS | Anmerkung |
|---|---|---|---|
nerdminer-v2 | 50 GH/s | nein | Plain only |
bitaxe-ultra | 2 TH/s | ja | Sauberer Reconnect |
nerd-octaxe | 4.5 TH/s | ja | NerdNOS-Quirks |
avalon-q | 90 TH/s | nein | CGMiner-User-Agent |
antminer-s23-pro | 580 TH/s | ja | ASIC-Boost overt |
adversary | — | — | Boshafte Inputs |
Failure-Modes
dvb-warppool-sim failures <mode> injiziert Production-Probleme:
rpc-down— Bitcoin Core unreachablezmq-disconnect— Block-Notify wegtls-handshake-fail— abgelaufenes Certminer-hang— Connection hängt nach N Sharesnetwork-latency— künstliche RTTpacket-loss— N% Paket-Lossinvalid-template— kaputte CB-Txreorg— Tip wird zurückgerolltmemory-pressure— Pool unter RAM-Druckdisk-full— SQLite-Write-Fail
Security-Suite
dvb-warppool-sim security fährt adversarielle Inputs (Line-Flood, Connection-Storm,
Slowloris, Auth-Flood, Weak-Cipher, ...).
Correct outcome: jede Connection sauber abgelehnt, kein Panic, kein OOM.
4. Regtest E2E (Phase 15b — opt-in)
Für volles E2E mit echtem bitcoind regtest:
eval "$(scripts/regtest-up.sh)" # startet bitcoind regtest + setzt env-vars
cargo test -p warppool-bitcoin-rpc --test regtest_e2e -- --ignored --nocapture
scripts/regtest-down.sh --purge # cleanup
Drei #[ignore]-Tests gegen das laufende regtest:
| Test | Was |
|---|---|
regtest_blockchain_info_returns_regtest_chain | chain == "regtest" |
regtest_getblocktemplate_works | Template hat version + bits + prev-hash |
regtest_submit_invalid_block_is_rejected | Trivially invalid hex → Rejected (nicht Accepted, nicht Error) |
CI führt sie nicht aus (kein bitcoind im Runner-Image). Lokal sind sie sehr nützlich vor Release-Tags. Siehe Configuration Reference → WARPPOOL_E2E_REGTEST_*.
5. Performance Benchmarks (Phase 19 — opt-in)
Criterion-Suites für Hot-Paths:
| Crate | Bench | Hot-Path |
|---|---|---|
warppool-share-validator | validate | per accepted+rejected share |
warppool-job-builder | build_job | per neuem Template |
warppool-stratum-v1 | vardiff | per accepted share |
cargo bench --workspace
# oder
cargo bench -p warppool-share-validator --bench validate
Siehe Performance Benchmarks für Baseline-Zahlen und Interpretation.
6. UI-Side (svelte-check)
cd ui
pnpm install
pnpm svelte-check
# → 294 files / 0 errors / 1 pre-existing warning
Pre-existing warning: Unused CSS selector "th.num" in blocks/+page.svelte
— historisches Artefakt aus Phase 4, kein Funktionsproblem.
CI
.github/workflows/ci.yml Matrix:
cargo fmt --check(no diff)cargo clippy --workspace --all-targets -- -D warningscargo test --workspacecargo deny check(license + advisories)cargo audit- UI-Job:
pnpm install+pnpm svelte-check+pnpm build
Separate Workflows (siehe Architecture):
release.yml— Tag-Triggered: Multi-arch Docker + native binaries + SLSA-3 + SBOMrepro.yml— Reproducible-Builds-Verify (zweimal bauen + sha256-diff)docs.yml— mdBook → GitHub Pagesbenches.yml— Criterion (manual-dispatch + tag-push, nicht-PR)
Performance Benchmarks
dvb-WarpPool uses Criterion for hot-path benchmarks. Three suites cover the critical code paths. The goal is not maximum throughput — a solo pool's workload is small — but a regression baseline so that code changes are caught early when they unexpectedly become expensive.
Suites
| Bench | Crate | Hot-Path | Frequency in Pool |
|---|---|---|---|
validate | warppool-share-validator | ShareValidator::validate() per accepted+rejected share | per-share (most frequent) |
build_job | warppool-job-builder | JobBuilder::build() per new block template | per-job (~every 30-60s) |
vardiff | warppool-stratum-v1 | VarDiff::observe_share() per accepted share | per-share |
Running Locally
# Single bench
cargo bench -p warppool-share-validator --bench validate
# All three
cargo bench --workspace --benches
# Compile-check only, no runs (CI smoke)
cargo bench --workspace --no-run
Criterion writes reports to target/criterion/<bench-name>/report/index.html.
On a second run it compares against the first and reports drift
(Performance has regressed. / Performance has improved.).
Baseline Numbers (2026-05-27, MacBook M-Series, release build)
These numbers are a snapshot, not a hard contract — they can vary by 2× depending on hardware and CPU throttling. On Linux x86_64 server hardware the values are typically similar or better.
validate (per share)
| Bench | Time | Throughput |
|---|---|---|
validate_full/0 (no merkle branches) | 1.32 µs | 760K shares/s |
validate_full/8 (typical regtest) | 5.49 µs | 182K shares/s |
validate_full/12 (typical mainnet) | 7.59 µs | 132K shares/s |
sha256d_80b_header | 528 ns | — |
sha256d_500b_coinbase | 1.55 µs | — |
merkle_root/12 | (hot-path portion) ~2 µs | — |
reconstruct_coinbase | < 200 ns | — |
build_header | < 30 ns | — |
Take-away: validate scales with merkle-branch count (linearly). At 12 branches the pool can validate ~130K shares/s — that's 1000× more than a solo pool with 7 Bitaxes will ever see (typically 1-5 shares/s). Validate is NEVER the bottleneck.
build_job (per job-refresh)
| Bench | Time | Throughput |
|---|---|---|
build_job/0 (empty / regtest) | ~100 µs | — |
build_job/100 | ~150 µs | — |
build_job/1000 | ~700 µs | — |
build_job/4000 (typical full block) | 2.59 ms | 386 jobs/s |
merkle_branches/4000 | 2.30 ms | — |
Take-away: Job-build scales with tx-count, dominated by merkle-branch computation. 2.59ms / job for a full mainnet block is clearly visible but not a problem — templates arrive every 30+ seconds, not every ms.
vardiff (per share, EMA-update + retarget)
| Bench | Time |
|---|---|
vardiff_observe_share_hold (stationary) | 5.2 ns |
vardiff_observe_share_retarget (8-share burst) | 37.9 ns (~5ns/share) |
difficulty_to_target_be | 12.85 ns |
vardiff_decision_variant_match | 432 ps |
Take-away: VarDiff is effectively free. Even under extreme load scenarios (>100K shares/s) it consumes <1ms/s of CPU.
Interpretation
What you can read from the numbers:
| Question | Hint |
|---|---|
| "Is my pool burning too much CPU?" | No. At 10 shares/s and 12 merkle branches: ~76 µs share-validate time per second = 0.0076% CPU |
| "How many workers can my pool serve at most?" | The Stratum connection cap (profile-dependent, 64-4096). Share-validate is not the limit |
| "Is ASIC-boost / merkle-tree caching worth it?" | No, not in a solo pool. In a 10M-shares/s pool, per-template merkle-branch caching would be a factor of 5-10 |
CI
.github/workflows/benches.yml runs only:
- Manual dispatch (operator clicks "Run workflow" in the UI)
- On tag push (release snapshot)
NOT on every PR — Criterion runs are expensive (~5min build + 5min suite), and GitHub-runner noise makes microbench comparisons unreliable.
Reports are uploaded as artifact criterion-reports-<sha> with 30-day
retention. The operator can download them and view them locally in the
HTML report.
Regression Workflow
When a bench suddenly becomes 50% slower:
- Run
cargo bench --bench <name>locally → confirm git bisectbetween the last known-good version and HEAD- On dependency bumps: inspect the Cargo.lock diff (often pulls a new version of a transitive dep)
Criterion automatically stores the last baseline in target/criterion/
— when you bench locally, it compares against YOUR last run, not
against GitHub. For a CI-vs-local comparison, download the artifact and
place it locally under the target/criterion/ path.
What is Deliberately Not Benched
| Path | Why not |
|---|---|
| Stratum V1 TCP I/O | Tokio async-IO is syscall-bound; criterion would be noise-dominated. tokio-console is more useful for inspection. |
| Bitcoin RPC | Network IO + Bitcoin-Core-side dominates. The Phase 16.3 RPC-latency histogram is the right observation. |
| Translator V1↔V2 mapping | Per-job (every 30-60s), not latency-critical. Would be effort for little benefit. |
| Storage SQL | sqlx + WAL mode dominates. If needed, bench directly with the sqlite3 CLI. |
| Notifier sinks | HTTP/SMTP IO, not CPU-bound. End-to-end latency is readable from the /metrics histogram. |
See Also
- Observability — Runtime metrics (Prometheus) instead of synthetic benches
- Testing — Unit / integration / sim tests
Phase History
The Sv2 stack was built incrementally in clearly scoped phases. Each phase delivers a green test state and a production-ready slice; no half-baked, half-merged branches.
| Phase | Content | Status | Tests* |
|---|---|---|---|
| 7.1 | Foundation — codec + 12 messages + NOISE_NX | ✅ | +22 |
| 7.2 | Mining-Protocol state machine | ✅ | +16 |
| 7.3a | Sv2 server crate (TCP + NOISE + session loop) | ✅ | +2 |
| 7.3b | Daemon wiring + CLI (gen-sv2-key) | ✅ | (live) |
| 7.4 | V1↔V2 translator proxy + extended channels | ✅ | +22 |
| 7.5 | Job distribution + V1 mining.notify mapping | ✅ | +11 |
| 7.5b | Production polish (prev_hash byte order + BIP-320) | ✅ | +6 |
| 7.6a | Template-distribution foundation (7 TDP messages) | ✅ | +13 |
| 7.6b | TDP daemon wiring (Bitcoin-node client) | ⏳ | — |
*Each phase additionally turned tests green. Workspace total: 356.
Phase 7.1 — Foundation
Workspace member warppool-stratum-v2 with three modules:
codec.rs— Sv2 frame format (6-byte header[ext u16 LE][type u8][len u24 LE]),MAX_PAYLOAD_LEN = 1 MiB, compact-int encoding (Bitcoin var_int compatible), length-prefixed strings.messages.rs—ProtocolMessagetrait + 6 concrete structs (SetupConnection- 2 replies, OpenStandardMiningChannel + reply, SubmitSharesStandard). No
serde, because the Sv2 spec mandates exact byte layouts; every message has
encode_payload/decode_payloadmethods.
- 2 replies, OpenStandardMiningChannel + reply, SubmitSharesStandard). No
serde, because the Sv2 spec mandates exact byte layouts; every message has
noise.rs—NoiseSessionstate machine oversnow::HandshakeState/TransportState. Pattern:Noise_NX_25519_ChaChaPoly_BLAKE2s.
Quirks: snow 0.10 builder methods are Result-returning (use ? instead
of chaining); OpenStandardMiningChannel nominal_hash_rate: f32 blocks Eq
derive — only PartialEq.
Phase 7.2 — Mining-Protocol State Machine
Pure logic layer with no TCP coupling: the caller feeds in decoded Frames
and gets 0..n response frames back.
Three session phases: AwaitSetup → MiningProtocol → Closed. Phase 7.6a
added TemplateDistribution as a parallel phase.
ChannelRegistry with per-channel extranonce prefix (4-byte BE counter),
duplicate-sequence detection via last_sequence_number tracking.
Phase 7.3 — Sv2 Server + Daemon Wiring
7.3a server crate: accept loop + NOISE handshake (responder) +
length-prefixed encrypted-frame I/O. JobUpdate broadcast as
broadcast::Sender<JobUpdate>.
7.3b daemon wiring: StratumConfig.sv2_listen + Secrets.sv2_static_priv_key_hex.
CLI subcommand gen-sv2-key prints the public key to stderr (for miner config)
and the private key to stdout (for secrets.toml via redirection).
The daemon can host V1 (plain + TLS) and V2 (NOISE) in parallel.
Phase 7.4 — V1↔V2 Translator Proxy
New crate warppool-translator + binary dvb-warppool-translator. For every
V1 connection the translator opens a V2 connection to the upstream pool in
parallel and translates between the wire formats.
Extended channels as a prerequisite: standard channels have no
extranonce field in the submit, but V1 miners iterate extranonce2. Solution:
the translator opens an OpenExtendedMiningChannel upstream and sends
SubmitSharesExtended with miner-controlled extranonce.
State machine:
- V1
mining.subscribe→ buffered (no reply yet) - V1
mining.authorize→OpenExtendedMiningChannel{user_identity, min_extranonce_size=4}upstream - V2
OpenExtendedMiningChannel.Success→ deferred V1 replies (subscribe result withextranonce_prefixas extranonce1, set_difficulty from target, authorize OK) - V1
mining.submit→ V2SubmitSharesExtendedwith pad/truncate tochannel.extranonce_size - V2
SubmitShares.Success/Error→ V1 OK/Err with Stratum codes (low-diff→23, stale→21, duplicate→22)
Also done in 7.4: real SubmitShares.Success/.Error frames from the
Sv2 server (the old Phase 7.3 TODOs "silent ignore + log only" became real
responses).
Phase 7.5 — Job Distribution + V1 Notify
Four new Sv2 messages for block-template push:
NewMiningJob(0x1E) — standard channels (pre-computed merkle_root)NewExtendedMiningJob(0x22) — extended channels (with version_rolling_allowed, SEQ0_255 merkle_path, B0_64K coinbase_prefix/suffix)SetNewPrevHash(0x20) — references the previous job byjob_idSetTarget(0x21)
Server fan-out: JobUpdate extended with coinbase_prefix/suffix/merkle_root/
min_ntime/version_rolling_allowed. In the handle_connection job-broadcast arm,
build_job_frames(session, job) builds the kind-appropriate variant for every
open channel + a deferred SetNewPrevHash.
Translator: learns NewExtendedMiningJob + SetNewPrevHash + SetTarget;
caches job + prev_hash separately and pairs them via job_id. maybe_emit_notify()
sends V1 mining.notify with all 9 params:
[job_id, prev_hash_hex, cb1, cb2, [merkle_branches], version, nbits, ntime, clean_jobs].
clean_jobs=true on tip change.
SetTarget with an unchanged target is skipped (avoids redundant
mining.set_difficulty spam).
Phase 7.5b — Production Polish
Two real V1-miner production bugs fixed:
prev_hash byte order: prev_hash_to_v1_hex() reverses every 4-byte chunk
(slushpool convention, sha256-block-internal order). Previously BE display
bytes were forwarded as-is → real V1 miners parse that incorrectly.
BIP-320 version rolling:
mining.configurehandler: negotiates mask =miner_mask & ours_mask;ours_mask = 0x1FFFE000(16 bits, bits 13–28) when the upstream job hasversion_rolling_allowed=true, otherwise 0.mining.submitparses an optional 6th paramversion_bits_hex.- V2
SubmitSharesExtended.version = (job.version & !mask) | (miner_bits & mask)per BIP-320 XOR — previously hardcodedversion: 0(MVP bug).
Phase 7.6a — Template-Distribution Foundation
7 new TDP messages (its own sub-protocol, separate from Mining):
| ID | Name |
|---|---|
| 0x70 | CoinbaseOutputDataSize |
| 0x71 | NewTemplate |
| 0x72 | SetNewPrevHashTdp (distinct wire format from Mining 0x20!) |
| 0x73 | RequestTransactionData |
| 0x74 | RequestTransactionData.Success (with SEQ0_64K<B0_16M> tx list) |
| 0x75 | RequestTransactionData.Error |
| 0x76 | SubmitSolution |
Session routing: new phase TemplateDistribution. TDP frames are logged
but the foundation phase emits no response — Phase 7.6b wires up the
real handling. Cross-protocol frames (Mining in TDP phase or vice versa)
are an UnexpectedMessage error.
Phase 7.6b — TDP Daemon Wiring (gated)
Waiting on Bitcoin Core gaining native Sv2 TDP server support. Today this only exists in SRI forks; no path runs in production.
What Remains for Phase 8
- Reproducible builds (deterministic timestamps,
--remap-path-prefix) - Cosign signatures (partly in Phase 6 — keyless OIDC for SHA256SUMS)
- SBOM via Syft (partly via anchore/sbom-action)
- Auto-update with rollback (larger feature, its own crate
warppool-autoupdate) - Documentation site ← this one (Phase 8a)
This documentation itself is Phase 8a.
Roadmap
Phase 1 — Foundation (THIS SESSION ✓)
- Workspace layout, Cargo.toml, .gitignore, .cargo/config.toml
-
warppool-profilescrate (Small/Medium/Large/Enterprise) — implemented + tested -
warppool-configcrate (schema port from dvb-gopool) — implemented + tested -
warppool-hwdetectcrate — hardware auto-detection + profile recommendation - Coinbase splits (pool fee + donation, default "No Pool Fee", relevant for Large/Enterprise / multi-user solo)
-
warppool-simulatorcrate (sim-miner / sim-node / failures / load / security / scenarios) - Stub crates with module docs as roadmap markers
-
Apps:
dvb-warppool-daemon,dvb-warppool-cli,dvb-warppool-sim— skeleton
Phase 2 — MVP "runnable" (complete, covered by 2.1/2.5 + Phase 3)
Goal: a real solo pool for 1-5 miners, plain Stratum V1, no TLS.
-
warppool-bitcoin-rpc— RPC + ZMQ subscribe (see Phase 2.1) -
warppool-job-builder— GBT conversion + coinbase -
warppool-share-validator— SHA256d check -
warppool-stratum-v1— plain TCP listener (TLS later in Phase 3) -
warppool-storage— SQLite + migrations + raw shares -
warppool-api— minimal/api/overview+/healthz -
dvb-warppool-daemon— wire it all together + graceful shutdown -
dvb-warppool-sim—solo-block-foundscenario functional (see Phase 3)
Phase 2.5 — VarDiff (THIS SESSION ✓)
-
warppool-stratum-v1::vardiff— EMA-based algorithm with hysteresis, max_step, min/max clamping -
warppool-storage—vardiff_statetable + load/save -
Sessionintegration — load on Authorize, observe on Accept, retarget with set_difficulty + pool-target update, async save -
[vardiff]config section +StorageVarDiffStoreadapter in the daemon - 26 new tests (vardiff core 17 + storage 3 + config 3 + e2e wiring 1 + daemon adapter 1 + e2e snapshot-restore test 1)
Phase 2.1 — ZMQ Subscribe (THIS SESSION ✓)
-
warppool-bitcoin-rpc::watch_hashblock— pure-Rustzeromqcrate, no libzmq required - Reconnect backoff 1s → 30s on disconnect
-
3-frame parser for
hashblock(topic / 32-byte BE hash / 4-byte LE seq) -
Daemon: optional ZMQ-watcher task →
mpsc::channel<ZmqBlockEvent>→job_refresh_loopselect arm. Polling remains as fallback. -
--no-zmqCLI flag for polling-only mode - 6 new tests (parse 4 + hash_hex + real PUB/SUB roundtrip)
Phase 3 — Security & Stability
-
TLS for Stratum V1 (THIS SESSION) —
Sessiongeneric overAsyncRead+AsyncWrite,tls::load_tls_config(rustls + aws-lc-rs),StratumServer::serve_tls_with_listenerwithtokio-rustls::TlsAcceptor, daemon optionally spawns a parallel TLS listener whenstratum_tls_listen+ cert/key are set. CLI flag--no-tls. E2E roundtrip with rcgen-generated self-signed cert green. -
Rate limiting (THIS SESSION) — Token bucket per peer-IP in
ratelimit::RateLimiter, two buckets (connect + auth), lazy cleanup for idle entries. In theacceptloop before the semaphore check, inhandle_authorizebefore the auth work.[ratelimit]config section. E2E test triggers auth burst-block. -
Auth Subphase 1 — Admin login + JWT session (THIS SESSION). Argon2id password hashing, HS256 JWT, cookie + Bearer header. Routes: POST
/api/auth/login, POST/api/auth/logout, GET/api/auth/whoami, GET/api/admin/ping(protected demo). AuthIdentity extractor: 401 without token, 503 when auth is not configured.dvb-warppool-cli hash-password+gen-jwt-secretsubcommands for setup. Whenadmin_password_hashorjwt_secretin secrets.toml are empty → auth is off, daemon logs a warning. -
Wire CLI to the API (THIS SESSION) — all TODOs replaced with real reqwest calls. New subcommands:
status,miners --limit,blocks --limit,profile,hashrate --worker --hours,login -u,whoami.--jsonglobal flag for scripting. Pretty-print with humanized hashrate units (H/s → EH/s). -
Sim-Runtime: solo-block-found for real (THIS SESSION) —
JsonStratumMineras a real TCP Stratum V1 client, persona-driven submit rate with Poisson jitter + 50ms cap.dvb-warppool-sim scenario solo-block-found --duration Nspawns in-process Stratum server (network_target=[0xff;32]) + sim-miner, waits for BlockFoundEvent, prints report. -
Sim-Runtime: connection-storm-defense for real (THIS SESSION) —
dvb-warppool-sim scenario connection-storm-defense --attackers Nspawns Stratum server with aggressive auth rate-limit (burst=10, 1/s) + N parallel TCP attackers. Validates the Phase-3 rate-limit live: 200 attackers → 10 success, 190 rate-limited. -
Sim-Runtime: enterprise-load-smoke for real (THIS SESSION) —
dvb-warppool-sim scenario enterprise-load-smoke --miners Nspawns N parallelJsonStratumMinerwith mixed personas (round-robin across 4 vendor profiles). Throughput test: 200 miners deliver ~3800 shares/s stable over 15s, 0 rejected. -
Auth Subphase 2 — API tokens (THIS SESSION) —
api_tokenstable with SHA-256 hash (plaintext never persisted), formatwpat_<32hex>.mint_api_token()+sha256_hex()in api/auth.AuthIdentityextractor distinguisheswpat_prefix → DB lookup viafind_active_token_by_hash; otherwise continues with JWT. Routes: POST/GET/DELETE/api/admin/tokens(with audit log). Soft-delete viarevoked_at. CLI subcommandstoken-create -n -- --ttl --scope/token-list/token-revoke <id>. -
Auth Subphase 3 — 2FA-TOTP (THIS SESSION) —
admin_2fatable per user (secret_base32, enabled, created_at, enabled_at). totp-rs crate (SHA-1, 6 digits, 30s, ±1 skew). Routes: GET/api/auth/2fa/status, POST/setup(mint secret + otpauth URL),/enable(verify code + set enabled),/disable(verify code + delete). Login optionally acceptstotp_code; required when 2FA is active. 401 withrequires_2fa: trueflag when code is missing. CLI subcommandstwofa-status/setup/enable/disable,login --totp CODE. Phase 3 complete with this. -
Profile hot-switch + X-Forwarded-For (THIS SESSION) —
pool_settingsKV table,AppState.profile_kindis nowArc<RwLock<ProfileKind>>. POST/api/admin/profile {kind}persists + audits + immediately visible in/api/profile//api/overview. Daemon reads persisted profile on startup (fallback config).server.trust_proxy_headers(default false) — when true: peer_ip is taken fromX-Forwarded-Forheader (first element). Protects against IP spoofing on direct internet access. -
Tiered retention + aggregation worker (THIS SESSION) —
shares_raw+shares_agg_5mintables,record_share+aggregate_5min+ evict methods in storage.ShareSinktrait in stratum-v1 (Session calls sink.record after Valid/BlockFound in a spawned task).StorageShareSinkadapter in the daemon. Aggregate loop every 60s with eviction (raw >1h, agg_5min >7d)./api/hashrate?worker=…&hours=…endpoint with 5-min-bucket output + approx_hashrate. -
Audit log (THIS SESSION) —
audit_logtable (id/at/actor/action/target/peer_ip/ok/details).AuditSinktrait inwarppool-api::auth+StorageAuditSinkadapter in the daemon. Auth routes firelogin.ok/login.fail. Protected endpointGET /api/admin/audit?limit=N&actor=.... Peer-IP via optionalConnectInfo<SocketAddr>(daemon usesinto_make_service_with_connect_info::<SocketAddr>). CLIaudit --limit --actorsubcommand with table print. -
CI: fmt, clippy, test, deny (in
.github/workflows/ci.yml+deny.toml)
Phase 4 — UX
- SvelteKit PWA with Modern UI palette (port from dvb-gopool) — Phase 1
-
UI Subphase 1: Login + auth state (THIS SESSION) —
lib/auth.svelte.tswith Svelte 5 runes ($state in.svelte.tsmodule), cookie-based session (credentials:'include' on all fetches), localStorage holds only username + marker (token is HttpOnly cookie)./loginroute with username/password + optional TOTP code (appears afterrequires_2fa: trueresponse). Header shows login link or username+logout button. -
UI Subphase 2: Admin cockpit (THIS SESSION) — Routes
/admin/profile(4-kind switcher),/admin/tokens(list + create + revoke with plaintext-once display),/admin/audit(table + actor/limit filter, tone-coded actions),/admin/2fa(status + setup wizard with secret/otpauth URL + code verify + disable).+layout.sveltewith auth guard (redirect /login if unauth). Header shows "Admin" link only when authed.lib/api.tsadmin.*sub-namespace with all endpoints. -
UI Subphase 3: Hashrate chart (with
/api/hashrate,HashrateChart.svelte) -
Live updates via SSE (
/api/sse→lib/events.svelte.ts) — WebSocket variant rejected, SSE is sufficient for read-only push and avoids reverse-proxy pitfalls -
i18n (DE/EN/JA/ZH) via
lib/i18n.svelte.ts+locales/*.json -
Push notifications: foreground via Browser Notification API (
lib/notifications.svelte.ts); real VAPID push (web-push crate) deliberately deferred due to native-OpenSSL dependency — will come with a pure-Rust web-push backend - Mobile-responsive (header collapsed, all main routes OK)
Phase 5 — Operations (complete)
-
Prometheus exporter
/metrics - Vendor API polling (AxeOS, NerdNOS, BitMain, Whatsminer)
- mDNS auto-discovery
- Notifier: ntfy + Telegram + Discord + Web Push + Email
- Backup/restore (config + DB)
Phase 6 — Packaging (complete, incl. RPi 5 first-class)
- Docker multi-arch (amd64 + arm64)
- Umbrel app manifest
- macOS .dmg (universal binary, notarized)
- Windows .msi (signed)
- Linux .deb / .rpm / .AppImage
- Tauri desktop bundle (optional)
- Raspberry Pi 5 first-class — dedicated arm64 builds, pinned for Pi 5
Phase 7 — Stratum V2
-
7.1 Foundation —
warppool-stratum-v2crate: codec (binary frame with compact_int), messages (SetupConnection, OpenStandardMiningChannel, SubmitSharesStandard, +Success/Error), noise (NOISE_NX_25519_ChaChaPoly_BLAKE2s viasnow) -
7.2 State machine —
MiningServerSessionwith AwaitSetup → MiningProtocol → Closed phases,ChannelRegistrywith per-channel extranonce prefix + duplicate-sequence detection -
7.3a TCP/NOISE server —
Sv2Serverwith accept loop, NOISE_NX handshake (responder), length-prefixed encrypted-frame I/O, JobUpdate broadcast - 7.3b Daemon wiring — Config + secrets.toml (static_priv_key persistent), daemon spawns V1+V2 in parallel when hosted, E2E test green
-
7.4 V1↔V2 translator proxy — Extended-channel messages (
OpenExtendedMiningChannel0x13,OpenExtendedMiningChannelSuccess0x14,SubmitSharesExtended0x1F,OpenMiningChannelError0x12,SubmitShares.Success0x1C,SubmitShares.Error0x1D) including server handler with real Success/Error responses (old Phase-7.3 TODOs resolved). PublicSv2Client(TCP+NOISE initiator, previously cfg(test) only). New cratewarppool-translator+ binarydvb-warppool-translator(clap CLI). Per V1 connection the translator opens a V2 connection, buffersmining.subscribeuntilmining.authorizearrives, then sendsOpenExtendedMiningChannelupstream.mining.submit→SubmitSharesExtendedwith miner-controlled extranonce;SubmitShares.Success/Error→ V1 OK/Err with correct Stratum codes. E2E test: mock-V1-miner → translator → Sv2 server → submit roundtrip. -
7.5b Translator production polish (THIS SESSION) —
prev_hashbyte order in V1mining.notifyis now slushpool-conformant (4-byte-chunk-reversed per group, so real V1 miners parse the hash correctly in sha256 block order — previously we passed BE display bytes through). BIP-320 version rolling:mining.configurehandler negotiates a mask between client and translator:agreed = miner_mask & ours_mask;ours_mask = DEFAULT_VERSION_ROLLING_MASK (0x1FFFE000, 16 bits Bit 13-28)if the upstream job reportsversion_rolling_allowed=true, otherwise 0. With agreed=0 → response{"version-rolling": false}.mining.submitparses optional 6th paramversion_bits_hex; with rolling active, the translator builds V2SubmitSharesExtended.version = (job.version & !mask) | (miner_bits & mask)(BIP-320 XOR). Previouslyversion: 0was hardcoded (pre-7.5b MVP bug). 4 new translator lib tests (prev_hash_to_v1_hex_reverses_4_byte_chunks, prev_hash_to_v1_hex_known_block_explorer_value, bip320_version_resolution_with_mask_applies_xor, bip320_default_mask_is_16_bits) + 2 new E2E (configure_negotiates_version_rolling_mask, configure_without_upstream_rolling_returns_false). 356/356 tests green (+6 vs 7.5). -
7.5 Job distribution + V1 notify mapping — Four new Sv2 messages:
NewMiningJob0x1E (for standard channels, with finished merkle_root),NewExtendedMiningJob0x22 (for extended, with version_rolling_allowed + SEQ0_255merkle_path + B0_64K coinbase_prefix/suffix), SetNewPrevHash0x20 (channel_id/job_id/prev_hash/min_ntime/nbits),SetTarget0x21 (channel_id/maximum_target). Sv2 server fan-out:JobUpdateextended with coinbase_prefix/suffix/merkle_root/min_ntime/version_rolling_allowed; in handle_connection job-broadcast arm,build_job_frames()builds the matching job variant + follow-upSetNewPrevHashfor each open channel (Standard → NewMiningJob+SNPH, Extended → NewExtendedMiningJob+SNPH); previously "log only". Translator learns NewExtendedMiningJob + SetNewPrevHash + SetTarget; caches both job halves, pairs them byjob_id,maybe_emit_notify()sends V1mining.notifywith all 9 params (job_id/prev_hash_hex/cb1/cb2/merkle_branches/version_BE/nbits_BE/ntime_BE/clean_jobs);clean_jobs=trueon tip change (new prev_hash). SetTarget with unchanged target is skipped (avoids redundant set_difficulty); on change →mining.set_difficulty(target_to_difficulty). Tests: 2 server tests (push_job_emits_new_extended_mining_job + ...new_mining_job), 2 translator lib tests (build_v1_notify 9 params + clean_jobs=false on same prev_hash), 1 new E2E (Sv2 handle.push_job → translator → V1 mining.notify with prev_hash×0x42 + hex-encoded cb1/cb2 "PREFIX-bytes"/"SUFFIX-bytes-yep" + merkle_path branches + clean_jobs true→false transition). 337/337 tests green (+11 vs 7.4 state). Remaining for 7.x: real version-rolling bit-mask handling (BIP-320), Standard-channel mining.notify (today the Standard job is emitted locally by the pool, the translator uses Extended), reorg edge cases. -
7.6a Template Distribution Foundation (THIS SESSION) — Seven Sv2 TDP messages (Phase 7.6a Foundation, no Bitcoin-node wiring):
CoinbaseOutputDataSize0x70 (Pool→Node),NewTemplate0x71 (Node→Pool, with future_template bit for pre-build, coinbase_prefix/outputs B0_64K, SEQ0_255 merkle_path),SetNewPrevHashTdp0x72 (note: separate wire format from Mining 0x20! template_id u64 + prev_hash + header_timestamp + nbits + target),RequestTransactionData0x73 (Pool→Node),RequestTransactionData.Success0x74 (with SEQ0_64K<B0_16M> transaction_list — u16 count + each tx u24-LE-prefixed),RequestTransactionData.Error0x75,SubmitSolution0x76 (Pool→Node with finished coinbase). Session state machine: new phaseSessionPhase::TemplateDistribution,handle_setupacceptsProtocol::TemplateDistributionProtocol(=2) and switches to the phase. TDP frames are accepted in the phase and acknowledged withtracing::debug!without response (Phase 7.6b wires the real handling). 10 new message roundtrip tests + 3 session tests (tdp_transitions_to_template_distribution, tdp_frames_accepted_without_response, mining_frames_in_tdp_phase_are_unexpected). 350/350 tests green (+13 vs 7.5). -
7.6b Template Distribution Wiring — deferred (2026-05-27 ecosystem re-eval): Bitcoin Core has decided against native Sv2 support (Issue #31098); instead, since Core 30.0 an experimental generic IPC Mining Interface is available — Sv2 functionality is to be implemented via a sidecar (e.g. SRI's
sv2-tp-client), not directly in the node. Three paths exist today: (A) Sv2 TDP client against Sjors' Bitcoin Core fork withbitcoin-sv2-tppackaging (uses Phase 7.6a messages 1:1, ~10-15 tasks); (B) IPC client against mainline Core 30+ via cap'n-proto bindings (~20+ tasks, Phase 7.6a will be orphaned for this path); (C) wait until the ecosystem (SRI vs IPC-only) stabilizes, 12-24 month horizon. Chosen: Option C — getblocktemplate+ZMQ via Phase 2.1 is functional and low-latency today; a switch would cost implementation effort without practical value for the pool operator. Re-eval when Bitcoin Core 31/32 marks the IPC API as stable or SRI's stack becomes production-mature.
Phase 21 — VAPID Web Push (complete)
-
21.1 VAPID crypto + CLI (THIS SESSION) — New
warppool-notifier::webpushmodule with pure-Rust crypto stack (p256for ECDH/ECDSA,ecev2 for AES-128-GCM Content-Encoding RFC 8188,jsonwebtokenfor VAPID ES256 JWTs, no openssl-sys).VapidKeys::generate()produces a P-256 keypair as base64url;WebPushSender::send(sub, payload, ttl)performs JWT sign + payload encrypt + HTTPS POST. CLI subcommandgen-vapid-keysprints a TOML snippet to stdout (+ pub-key to stderr for UI hint). secrets.toml fieldsvapid_public_key+vapid_private_key+vapid_contact. 6 unit tests. -
21.2 Subscribe + public-key API (THIS SESSION) —
GET /api/push/vapid-public-key(public, no-auth, returns base64url key for the UI'sPushManager.subscribe),POST /api/push/subscribe {endpoint, p256dh, auth, label?}(public so subscribe without login is possible, validates HTTPS endpoint + non-empty keys),DELETE /api/push/subscribe {endpoint},POST /api/push/test(admin-required, fires test payload to all subs, returns sent/failed/gone_removed counts). AppState extended withvapid_public_key+web_push_sender: Option<Arc<WebPushSender>>. -
21.3 Daemon push-send loop (THIS SESSION) —
push_send_loopsubscribes toPoolEventBus, fires on BlockFound/HealthSnapshot-with-warnings/UpdateAvailable. Per sub, spawn a task so a slow push service doesn't block the others. OnWebPushError::Gone→delete_push_subscription_by_id(browser invalidated the sub). On other errors →touch_push_subscription(error)for UI debugging.render_push_eventhelper filters + renders events to title/body/tag tuples (returns None for events that should not be pushed). -
21.4 UI subscribe flow + service worker (THIS SESSION) — New
ui/static/service-worker.js(minimal SW with push + notificationclick + pushsubscriptionchange handlers, requireInteraction for block-found). NewwebPushstore innotifications.svelte.tswith subscribe()/unsubscribe()/refresh() + status machine ('idle'|'unsupported'|'no-service-worker'|'denied'|'subscribed'|'unsubscribed'). VAPID key is fetched at runtime from/api/push/vapid-public-key(no build-time secret). New BentoCard "Background Push (VAPID Web Push)" in/admin/notificationswith subscribe/unsubscribe button + iOS hint (required: add to home screen) + operator setup snippet. -
21.5 Tests + docs (THIS SESSION) —
notifications.mdnew "Web Push (PWA, VAPID)" section with operator setup + user subscribe flow + events that push + iOS quirks + security note.configuration-reference.mdnew secrets section for VAPID keys. ROADMAP.md amended. 520 tests green (+6 vs Phase 22.5, all 6 in the webpush module).
Phase 20 — Live Energy + Health Trend (complete, MVP)
-
20.1 Storage + electricity-tariff config (THIS SESSION) — New
ElectricityConfiginMiningConfig.electricitywith 3-layer priority (Solar/TOU/Default). Pure-fneffective_rate(now)+slot_matcheshelper, wrap-around slot support (22:00-06:00), weekday filter. Storage:miner_telemetry_raw(1h retention) +miner_telemetry_agg5(7d).record_miner_telemetry+aggregate_miner_telemetry_agg5+evict_*+miner_telemetry_history. Daemon wiring in miner_poll_loop + aggregate_loop. +13 tests (9 config + 4 storage). -
20.2 Live energy + history endpoints (THIS SESSION) —
GET /api/miners/:id/history?hours=N(auto-selects raw≤1h vs agg5) +GET /api/energywith current_power_w, current_eur_per_hour, last_{24h,7d}_kwh+eur, current_rate_source ("tou:HT"|"default"|"none"). Storage helperenergy_kwh_in_last_hours()aggregates across all miners. Pure-fnssum_current_power+describe_rate_sourceseparately testable. +8 tests. -
20.3 Health anomaly detector (THIS SESSION) — Pure-fn
detect_anomalies(points, now, thresholds)in newwarppool-storage::anomaliesmodule. 5 heuristics: StaleData (5min stale → Critical), FanStuck (rpm<1000 @ >30W consistent → Critical), VoltageDrop (<1050mV → Warning), ThermalThrottling (≥80°C + 15% hashrate drop → Warning), HashrateDrop (>30% drop without thermal context → Warning). ConfigurableAnomalyThresholds. Stale-data blocks other heuristics (no point analyzing an inactive miner). Thermal+Hashrate are deduplicated (no double-alert). API endpointGET /api/miners/:id/alerts. +13 tests (11 detector + 2 api). -
20.5 Solar HA provider (THIS SESSION) — Home Assistant REST API adapter.
SolarConfigextended withkind("home_assistant"),url_env/token_envfor auth,pv_entity_id(required) + optionalconsumption_entity_id,poll_interval_secs(default 60),surplus_buffer_w(default 200W),stale_after_secs(default 300s),excess_rate_eur_kwh(default 0.0). NewSolarSnapshot {pv_w, consumption_w, at}+SolarSnapshotCache = Arc<RwLock<Option<...>>>type.effective_raterestructured:effective_rate_with_context(now, solar_snap, pool_power_w)is the new main procedure with 3-layer priority. API side: Pure-async-fnfetch_ha_entity_wparses HA's/api/states/<id>response incl. unit conversion (W/kW/MW).fetch_ha_snapshotorchestrates PV + optional consumption./api/energyresponse extended withsolar: Option<SolarStatus>field (pv_w/consumption_w/excess_w/age_seconds) and newcurrent_rate_source = "solar-excess"value. Daemon:solar_poll_looptask spawns whensolar.enabled, writes to the shared cache. Failures are ignored (last-known snapshot stays until stale). +10 tests (7 config solar-logic + 3 api fetch_ha + 1 e2e solar-excess endpoint). 508 tests green (+10 vs Phase 20.4b). -
20.4b Per-miner detail page (THIS SESSION) — New dynamic route
/miners/[id]with header (host + vendor + last_error badge), health-alerts card (alerts from/api/miners/:id/alertswith severity tone), history card with range picker (1h/6h/24h/7d) + 5 TelemetrySparkline components (Hashrate/Power/Temp/Voltage/Fan). Refresh 30s.TelemetrySparkline.svelteas a simple SVG line chart, parameterizable viafield/label/fmtValue/yMin/accent(plasma/warp/btc/lime). Detail link/miners/[id]in the card-foot of the /miners list. 298 svelte-check files / 0 errors. -
20.3b HealthAlert notifier hook (THIS SESSION) — Closes the 20.3 gap. New
Event::HealthAlert{miner_label, alert_kind, severity, message}enum variant in warppool-notifier. All 5 sinks get anon_health_alert: boolconfig field (default true, Critical alerts indicate hardware damage). Sink-specific rendering: ntfy withrotating_lighttag + priority 5 for Critical; Slack with:rotating_light:emoji + severity label in body; Telegram uses render_text fallback. Daemon-sideanomaly_check_loop(env-gated:WARPPOOL_ANOMALY_CHECK_INTERVAL_SECS=300 default, 0=off). Per-(miner_id, alert_kind) debounce 30min (default,WARPPOOL_ANOMALY_DEBOUNCE_SECS) so not every 5-min tick fires a notification. Only Critical severity fires (FanStuck, StaleData) — Warnings stay UI-only. Tests from existing notifier suite (all 19 green after field extension). configuration-reference.md new env-vars table. -
20.4 UI EnergyCard (THIS SESSION) — New
EnergyCard.sveltecomponent on the dashboard with 4 StatTiles (Watt / €/h current / 24h kWh+€ / 7d kWh+€), tariff source display, "tariff not configured" hint when rate_source="none". Refresh cadence 30s in sync with miner_poll_loop. i18n keysenergy.*in de + en (six other locales get English fallback until translated). Per-miner history charts + alerts display deferred as 20.4b. svelte-check 295 files / 0 errors / 1 pre-existing warning.
Phase 32 — Security audit pass + re-verification (complete)
-
32.1 Audit methodology (THIS SESSION) — 5 parallel audit agents over ~31K LOC, one per attack surface (Funds/Consensus, Stratum-V1+Translator, Stratum-V2/NOISE, API-Authz/Auth, Data/Secrets/Supply-Chain). Every finding verified against the real code, then a second independent agent run for verification. 568 tests green (+12 vs 556), clippy
-D warningsclean. -
32.2 🔴 CRITICAL: Merkle root wtxid→txid + byte order (THIS SESSION) —
job-builderusedtx.hash(segwit wtxid) instead oftx.txidAND did not reverse the tx hashes from GBT display order into internal order. Consequence: on EVERY network with a non-empty mempool, wrong merkle root → Bitcoin Core rejects withbad-txnmrklroot, while the self-consistent pool-internal PoW check falsely reports "block won" = silent reward loss on mainnet. Latent because all live tests (Phase 29/31) used regtest with empty mempool. Fix:hex_to_32_reversed(&tx.txid). Known-answer test against real mainnet block 100000 + regression guard (txid-reversed vs raw-wtxid). -
32.3 HIGH fixes (THIS SESSION) — (a) V1+Translator line cap 16 KiB via
LinesCodec(was unbounded → OOM via 1 connection); (b) V1 pre-auth handshake timeout 60s (NO post-auth idle timeout, since low-HR miners at diff 1.0 may be idle up to ~24h/share); (c) Dedup-set leak:push_job→validator.clear_all()(was unbounded for the process lifetime); (d) Sv2 connection cap:max_connectionswas dead config → now a semaphore (0=unlimited); (e) Sv2 extranonce length check inprecheck_submit(invalid-extranonce-size); (f) unauth Web-Push SSRF:is_safe_push_endpoint(https + private/loopback/link-local/CGNAT/localhost block) on subscribe + send. -
32.4 MEDIUM/LOW fixes (THIS SESSION) — DB perms (data dir 0700 + DB+WAL+SHM 0600, unix — the documented chmod-600 assumption did not previously exist in code); setup wizard no longer overwrites existing
secrets.toml(was data loss: jwt_secret/sv2-key/vapid/rpc were wiped on re-run); login timing oracle (Argon2 now always runs, no username enumeration); HTTP login throttle (10/5min per IP, before Argon2 → also CPU-DoS protection); newserver.cookie_secureconfig flag (session cookie; Securewhen behind TLS). -
32.5 Re-verification + follow-ups (THIS SESSION) — 2nd agent run confirmed all 11 fixes as CORRECT/non-bypassable, NO authz regression (every mutating route checked route-by-route); bypass vectors exercised (1-MiB extranonce truncation, channel-kind confusion,
[::ffff:127.0.0.1]/hex-IP/uppercase/trailing-dot SSRF) → all blocked. 3 new small items found + fixed:pooltag_prefixlength validation (≤64 bytes — same silent-block-loss class as Merkle: too long → coinbase scriptSig >100 bytes), Sv2read_bufcap atMAX_NOISE_FRAME(was ~1 MiB/connection pinnable), Merkle regression test vector watertight. -
32.6 Merkle fix live-verified (non-empty mempool) (THIS SESSION) — Closed the blind spot that hid the CRITICAL bug: brought up regtest, wallet + 101 blocks, broadcast 20 transactions into the mempool (getblocktemplate returns
num_txs=20), daemon against regtest, Python Stratum V1 sim-miner. Result: pool builds merkle over 21 leaves (coinbase + 20 tx,merkle_branches=5), sim-miner finds a block (regtest network target trivial) → daemonsubmitting block bytes=6639→ "BLOCK ACCEPTED — Solo won". Bitcoin Core: chain 101→102, block 102 contains 21 tx, merkleroot accepted, mempool empty afterwards. With the old code, Core would have thrownbad-txnmrklroothere. The fix is confirmed end-to-end against real Core block validation (not just unit-tested). Stack cleanly torn down afterwards (regtest --purge). -
32.7 Sv2 Standard-channel support complete (THIS SESSION) — Closes the functional gap from the audit (Standard channels got
merkle_root=[0u8;32]→ bogus job, and the submit path did not buffer a BlockSolution → no block). Now:JobUpdate/JobSnapshotcarryextranonce_total_size(= V1 extranonce1+2); newpow::standard_extranoncefills the extranonce gap deterministically per channel (4-byte prefix + null padding);build_job_framescomputes the real per-channel merkle root (prefix + standard_extranonce + suffix → txid → merkle_path); newpow_check_standarddoes the full PoW check + buffers aBlockSolution(same daemon submit path as Extended).merkle_rootfield removed fromJobUpdate(was wrong for Standard, ignored for Extended). +3 tests (standard_extranonce helper, computed root in NewMiningJob, Standard BlockFound buffers solution). Remainder: no native Sv2 standard-channel live test (would need a NOISE-capable Sv2 sim-miner; the translator uses Extended) — the coinbase/root math is unit-tested, the submit path is the live-verified shared one. 570 tests green. -
32.8 Signet live test (real public network) + getblocktemplate signet-rule fix (THIS SESSION) — Signet node synchronized (height ~306k), daemon run against it. 🐛 Bug found:
getblocktemplatewas hardcoded with["segwit"]— Signet requires["segwit","signet"]→ RPC error -8, no template (occurs ONLY on Signet, never regtest/main/test). Fix: network-dependent rules viagbt_rules(network)in bitcoin-rpc ("signet"only on Signet, since other networks reject the rule) + unit test;get_block_template(network)signature. Then live-verified: daemon builds jobs over 59–61 REAL Signet mempool tx (incl. Taproot, nbits1d15102a,merkle_branches=6); sim-miner 300 submits → 300 LowDifficulty / 0 errors (validator computes real PoW against real target); soak: 3 job refreshes/min with live mempool tracking (59→61 tx), no panics, stable. Block acceptance with real tx remains proven on regtest (Signet PoW is unreachable for a CPU). 571 tests green. -
32.9 Polish (THIS SESSION) — mDNS log noise dampened (
mdns_sd=offin the default log filter — the ERROR spam "Invalid DNS message" on malformed LAN packets, noticed during the Signet test); Sv2ChannelRegistrycounter overflow-safe (wrapping_addinstead of+= 1, no debug panic on u32 overflow);codec::decode_str_u8now returnsFrameError::InvalidUtf8on UTF-8 error instead of misleadingBadCompactInt; Sv2 connection-cap hot-switch coupled to the admin profile switch (semaphore pulled from the serve loop intoSv2ServerHandle+resize_connection_cap/connection_capanalogous to V1; daemonStratumCapResizernow resizes V1 AND V2 late-bound). +2 tests, 573 green, clippy clean. -
Accepted residual risks / minor follow-ups (detail in SECURITY.md): miner-IP disclosure on unauth read endpoints (gate before public exposure); setup-wizard unauth file-read/SSRF on localhost (CSRF/Origin check before non-loopback bind); Web Push DNS-rebinding TOCTOU;
SecretsDebugderive (latent footgun); native Sv2 standard-channel live test (tooling missing); Sv2 extranonce-prefix only per-connection unique, not global (audit INFO; minimal impact for solo — deliberately not churned in the hardened connection path); Sv2 dynamic VarDiff (channel target fixed at diff-1; for solo only share accounting is affected, not BlockFound — separate feature, no polish).
Phase 31 — Sv2 live test + daemon job-broadcast bug (complete)
-
Sv2 + translator live-test setup (THIS SESSION) — Stack: Bitcoin Core regtest + dvb-warppool-daemon with
stratum.sv2_listen=127.0.0.1:3334+secrets.sv2_static_priv_key_hex+ translator sidecar (dvb-warppool-translator, V1-listen :3335 → V2-connect :3334) + Python sim-miner against the translator. Daemon starts cleanly, all listeners up. -
🐛 BUG FOUND: daemon push_job-to-Sv2 missing (THIS SESSION) — The
sv2_handlewas consumed in the daemon setup bysv2_server.serve(sv2_handle), without anyone cloning the handle beforehand to push jobs in. Result: Sv2 server accepts TCP connections + NOISE handshake completely, but never broadcasts a job — translator waits forever for aNewExtendedMiningJobframe and the V1 sim-miner never receives amining.notify. Phase 7.3b notes said "job push is forwarded to the handle below in job_refresh_loop" — but that was never written. Fix:Sv2ServerHandleannotated with#[derive(Clone)](was previously unique-owned); in the daemon setupsv2_handle.clone()into a pushable variable beforeserve();job_refresh_loopsignature extended withOption<Sv2ServerHandle>; afterhandle.push_job(...)(V1) additionallysv2.push_job(JobUpdate {...})for V2 with mapping from V1StratumJob(prev_hash byte-reverse back to BE, coinbase_1/2 → prefix/suffix bytes, merkle_branches → merkle_path, version_rolling_allowed=true). Live-verified after re-build: generatetoaddress 1 → daemon emits "new job" → Sv2 broadcast → translator receives NewExtendedMiningJob+SetNewPrevHash → V1 mining.notify → 20/20 shares accepted. Chain 125 → 126. - Sv2 V1↔V2 translator E2E verified (THIS SESSION) — Full path live: V1 sim-miner (TCP :3335) → translator converts mining.subscribe + mining.authorize to Sv2 SetupConnection + OpenExtendedMiningChannel → NOISE_NX handshake against daemon Sv2 server → SubmitSharesExtended upstream → SubmitSharesSuccess back → V1 OK to sim-miner. 20 accepted / 0 rejected / 0 job rotations (regtest network target allows almost every share as Valid, but not all as BlockFound — probably because the Sv2 path has a different validator config; see follow-up). Meaning: The entire Phase-7 stack has run end-to-end for the first time — the 71 unit tests + 6 translator E2E tests were correct, but the integration in the daemon was missing the 1 push.
Phase 31.7 — Sv2 real PoW check + BlockFound path (complete)
-
Sv2 server does real PoW check (THIS SESSION) — Before this fix, the Sv2 server used
AcceptAllValidator→ every share Valid, never BlockFound. The live test (Phase 31) showed: 20 shares accepted, 0 job rotations = no block-found detection. Fix in several parts: (1) Newpowmodule incrates/stratum-v2/src/pow.rswithsha256d/reconstruct_coinbase/compute_merkle_root/build_header/nbits_to_target/hash_meets_target(+ 6 unit tests, sha2 dep new); (2)MiningServerSessiongetscurrent_job: Option<JobSnapshot>, set viarecord_current_jobfrom the server loop after every broadcast (server loop convertsJobUpdate.prev_hash_be→ internal LE per byte-reverse); (3)handle_submit_share_extendeddoes inline PoW check (pow_check_extended): coinbase = prefix+pool_extranonce+miner_extranonce+suffix → txid → merkle_root via path → 80-byte header → sha256d → compare vsnbits_to_target(BlockFound) orchannel.target(Valid/LowDifficulty). Fallback to custom validator when no job is present. -
BlockFound event path to daemon submitblock (THIS SESSION) — On BlockFound, the session buffers a
BlockSolution(worker + coinbase_bytes + block_hash_be + ntime/nonce/version). Server loop drains viadrain_block_solutions+ broadcasts via newSv2ServerHandle::block_solution_tx. Daemonblock_submit_loopextended: second select arm for Sv2 solutions, converts viasv2_solution_to_eventto V1BlockFoundEvent(Sv2 coinbase already has extranonce built in = exactly whatbuild_full_block_hexexpects as stripped coinbase). Submit logic extracted into sharedsubmit_found_blockfn (V1 + Sv2 share it). Daemon subscribessubscribe_block_solutions()when Sv2 is active. -
Live-verified: BLOCK ACCEPTED via Sv2 (THIS SESSION) — Full stack: V1 sim-miner → translator → NOISE_NX → Sv2 server → PoW check → BlockSolution → daemon → submitblock → "BLOCK ACCEPTED — Solo won". 6 job rotations = 6 block-founds, chain 125 → 135 (+10 blocks via the Sv2 path). 556 tests green (+6 pow module), clippy
-D warningsclean. Third and last Sv2 live-test bug fixed — the entire Phase-7 stack is now production-functional incl. real block submission.
Phase 31.5 — Sv2 ShareSink + worker-counter bug (complete)
-
Sv2 ShareSink trait + wiring (THIS SESSION) — Before this fix, the Sv2 server had no path to persist accepted shares — only the V1 path had a
ShareSink. Result from the live test: 20 Sv2 shares passed through via translator, all accepted, 0 of them inshares_raw/pool_stats. Fix: (a) NewShareSinktrait incrates/stratum-v2/src/server.rsanalogous to V1,Sv2Server::with_sinks(...)constructor; (b)MiningServerSessioncollected accepted-share records (worker + difficulty + was_block) in an internalaccepted_buffer, server loop drains after everyprocess_frameand spawnsshare_sink.record()per entry (non-blocking); (c) Difficulty is computed from the channel target via top-8-byte approximation (sv2_target_to_difficulty); (d) DaemonStorageShareSinkimpls both V1ShareSinkand V2warppool_stratum_v2::server::ShareSinktraits — samerecord_sharecall underneath. -
🐛 BUG FOUND: worker counter generally broken (THIS SESSION) — While implementing the above Sv2 wiring it turned out:
Storage::record_sharewrites only toshares_raw, not to theworkerstable.pool_stats.total_shares_acceptedhowever aggregatesSUM(shares_accepted) FROM workers— i.e. the counter has been permanently 0 since the beginning, also for V1. In the previous live test (Phase 29) this didn't show because we only checkedtotal_blocks_found, which is updated via separatetouch_worker(_, _, _, 1, _)call in theblock_submit_loop. Fix:record_sharenow additionally callstouch_worker(worker, 1, 0, 0, None)— accepted counter += 1, rejected/blocks stay zero (blocks_delta is still set separately byblock_submit_loop; otherwise it would be double-counted). Live-verified: 20 Sv2 shares →total_shares_accepted: 20,total_workers: 1, workers table shows correct entry. 550 tests green (no test breaks since existing tests usedtouch_workerdirectly, not via the record_share path).
Phase 30 — Setup-wizard Bitcoin-Core install detection (complete)
-
30.1 Backend
/api/bitcoin-install-status(THIS SESSION) — New GET endpoint inapps/dvb-warppool-setup/src/main.rsthat checks: (a)bitcoind(orbitcoind.exeon Windows) in PATH via pure-Rust PATH walk, (b) executebitcoind --versionfirst line if binary found, (c) OS detection viastd::env::consts::OS, (d) Linux distro detection via/etc/os-release(ID + ID_LIKE → debian/fedora/arch/alpine/other). ReturnsBitcoinInstallStatus { binary_path, version_string, os, linux_distro, suggestion }. Pure-fninstall_suggestion_for(os, linux_distro)separately exported for unit tests. -
30.2 UI install card in setup wizard (THIS SESSION) — New card "Bitcoin Core — Installation" directly above the existing Bitcoin Core RPC card in
apps/dvb-warppool-setup/src/index.html. Loads/api/bitcoin-install-statuson page start. Two render paths: (a) if installed: green ✓ marker + version string + binary path + hint "if RPC below fails, check whether bitcoind is running". (b) if not: yellow ❌ marker + OS detection + copy-paste-ready command in<code>box with copy button (navigator.clipboard) + sudo warning if needed + download link to bitcoincore.org as button + notes list (700 GB storage, 1-3 days IBD, pruning hint). Card getsinstall-card-warnclass when missing → orange border. Operator workflow: copy command → terminal → install → reload page → ✓. -
30.10 Starfield 60 stars in main UI + setup wizard (THIS SESSION) —
ui/src/lib/components/Starfield.svelteupgraded from 15 to 60 stars in 2 drifting layers (80s + 120s reverse, opacity 0.7 for layer 2). Warp-Drive palette retained (white ~60%, plasma-cyan, warp-purple, btc-orange). Visibility tuning from dvb-goPool patched.69 adopted: sizes 1.5-3px (instead of 1-2px), alpha .35-.95 (instead of 0.30-0.80), ~12% "hero" stars with α ≥ 0.70 for Retina/iPhone visibility (sub-pixel AA otherwise washes out small stars). Positions deterministic via Python seed 0xDBC0DE. Setup-wizardindex.htmlwith identical values — layer 2 via.stars::afterpseudo-element (setup wizard has only 1.starsdiv).@media (prefers-reduced-motion: reduce)respected. - 30.9 Setup-wizard starfield stub replaced with dvb-goPool values — rejected in favor of 30.10 — First attempt adopted the dvb-goPool values 1:1 (60 static stars with 4 ambient glow decals + Bitcoin-orange + blue palette). Then on request rejected in favor of 30.10 (main-UI animation + Warp-Drive colors) — the latter fit the brand concept better and are cross-surface consistent.
-
30.8 Wordmark + favicon in setup-wizard header (THIS SESSION) — Wordmark PNG (1×+3× retina) + favicon.svg via
include_bytes!("../../../ui/static/...")compile-time-embedded into the setup binary → self-contained binary. Three new axum routes (/wordmark.png,/wordmark@3x.png,/favicon.svg) with correctContent-Type+ Cache-Control. Header<h1>replaced by<img srcset>analogous to main UI header (78px height, max-width: 80%, cyan drop shadow); mobile-responsive at 56px.<link rel="icon">in<head>. -
30.4 Brand name fixed + server location insertable (THIS SESSION) —
dvb-WarpPoolis no longer operator-configurable in the setup wizard (UI card "Branding" replaced with a pure "Server Location" card, default value"Korschenbroich NRW, Germany"). Backend inapply()deliberately ignores incomingstatus_brand_namefields (let _ = form.status_brand_name;) so older wizard versions or malicious POSTs cannot overwrite the brand.BrandingConfig::default()setsstatus_brand_name = "dvb-WarpPool"as hardcoded brand identity. Live-verified: POST with injectedstatus_brand_name="someone-tries-to-rebrand-this"→ config.toml showsstatus_brand_name = "dvb-WarpPool"(ignored) andserver_location = "Korschenbroich NRW, Germany"(accepted). -
30.5 Submit button only active when required fields filled (THIS SESSION) — Analogous to dvb-goPool:
#submit-btnis initiallydisabled+ greyscale, only becomes active when all required fields are valid. Reactiveinputlistener on 5 fields (payout_address, rpc_url, zmq_hashblock_addr, pool_fee_percent, pool_fee_address). Sub-hint under the button shows either "✓ All required fields filled" (green) or "Required fields missing: …" (orange) with concrete list of missing inputs. Double-check in submit handler prevents anyone from removing thedisabledattribute via DevTools. Format validation: bech32/legacy Bitcoin address, http(s):// URL, tcp://host:port ZMQ. -
30.6 ZMQ hashblock as required field (THIS SESSION) —
zmq_hashblock_addrwas previously optional. Now required so the operator does not accidentally leave it empty and run the pool in the 30s polling fallback (block latency 30s = race against faster pools lost).requiredattribute on<input>+ JS validation checkstcp://host:portformat. ZMQ rawblock remains optional (less used in the current daemon path). -
30.7 Setup-wizard language switcher DE/EN (THIS SESSION) — Vanilla-JS i18n in the embedded
index.html:I18Nobject with ~55 keys × 2 locales (DE as default, EN as fallback). All h2/labels/buttons/hints/notes havedata-i18n="key", placeholders withdata-i18n-placeholder, HTML-permitted withdata-i18n-html. Locale picker top right (🇩🇪 DE / 🇬🇧 EN) with active state. Detection: localStorage > navigator.language (de* → DE, otherwise EN).t(key)fn falls back to EN if key is missing in current locale; if missing in both, the key itself is shown (visible so missing keys are spotted). Dynamic strings (validation hints, install status, copy-button text) uset()directly; install status is re-rendered on locale switch via cachedwindow._lastInstallStatus.<html lang>is automatically set for screenreader correctness. -
30.3 Pure-fn tests (THIS SESSION) — 7 new tests for
install_suggestion_for: macOS (Homebrew), Debian (APT with sudo + old-versions warning), Fedora (DNF), Arch (pacman), unknown-linux-distro (no command, only download link), Windows (winget), unknown-OS (only download link). Subprocess calls (which,bitcoind --version) NOT tested — these are pure system calls without business logic. 550 tests green (+7 vs 543).
Phase 29 — Live test (regtest, local) + submit_block bug (complete)
-
Live-test setup (THIS SESSION) —
brew install bitcoin(Core 31.0.0),scripts/regtest-up.shstarts bitcoind-regtest with RPC :28443 + ZMQ hashblock :28332 + cookie auth. Local config in/tmp/warppool-live/{config.toml,secrets.toml}with regtest addressbcrt1qtau…tklgl8mmt, profile=klein, min_diff=0.001, ratelimit off. Daemon--releasebuild (10.9 MB) run against the regtest node, UI served by the daemon via--ui-dir. 101 blocks generated viageneratetoaddressto end IBD + mature coinbase. -
End-to-end verification (THIS SESSION) — Minimal Python Stratum V1 client (
sim_miner.py) connects to :3333, does subscribe → set_difficulty=0.001 → authorize → mining.notify → 20 mining.submit calls with random nonces. Full path coverage: Bitcoin Core RPC + ZMQ + GBT + Job-Builder + Stratum server + validator + submitblock — all run live. Result over several bursts: chain 101 → 125 (+24 blocks), pool reports 20 blocks_found, all asaccepted: truein/api/blocks(see next point for the bug fix that enabled this). -
🐛 BUG FOUND: submit_block null response (THIS SESSION) — Bitcoin Core's
submitblockRPC returns"result": nullon successful submit. Thebitcoin-rpcclient incall_once(crates/bitcoin-rpc/src/lib.rs:224) calledenvelope.result.ok_or(MissingField("result"))?, which onOption<Value>deserialize maps both missing-field and value-null toNone→ falseMissingFielderror. Result in the first live run: pool reported "rpc-error" +accepted=falsefor all found blocks, even though Bitcoin Core actually accepted them (chain height grew nonetheless). Fix:unwrap_or(serde_json::Value::Null)instead ofok_or(MissingField)—submit_block's match arm already correctly implemented theValue::Nullpath. 2 new tests:submit_block_null_response_classified_as_acceptedandsubmit_block_string_response_classified_as_rejected(raw tokio TcpListener as HTTP mock, no extra axum dep). 543 tests green (+2 vs 541). Significance: This bug could have silently broken a mainnet pool — all blocks would have been "found" but appeared as "rejected" in the UI, operator would have spent hours hunting a non-existent RPC problem.
Phase 28 — Eviction policy + cap hot-switch + SSE reconnect (complete)
-
28.1 DB eviction policy (THIS SESSION) — Three DB tables grew unbounded or with hardcoded TTLs to date:
audit_loghadevict_audit_older_thanin storage but was never called in the daemon → real gap since Phase 3.shares_raw/shares_agg_5min/miner_telemetry_raw/miner_telemetry_agg5had evict calls inaggregate_loopbut with literal hardcoded3600/7*24*3600values. NewRetentionConfigin crates/config/src/lib.rs as[retention]block with 5 TTL fields (defaults match the old hardcoded values;audit_log_secsdefault 90 days).aggregate_loopsignature extended withretention, all evict calls now use config values.audit_logeviction only once per hour (last_audit_evict Instant tracker) so the DELETE on a 90d table doesn't pay in every 60s tick. -
28.2 Connection-cap hot-switch (THIS SESSION) — Profile hot-switch (Phase 3) had left the Stratum semaphore at the old cap size. New
StratumServerHandle::resize_connection_cap(new_cap)in crates/stratum-v1/src/server.rs: increase viasemaphore.add_permits(delta)(immediately visible); decrease via spawnedacquire_many_owned(delta).forget()task that waits until enough permits free up — natural drain without kicking existing connections. Current cap is held in the newcap_tracker: Arc<AtomicUsize>in the handle. NewConnectionCapResizertrait in thewarppool-apicrate (crates/api/src/lib.rs);AppState.connection_cap_resizeras optionalArc<dyn ...>(Nonein tests).post_admin_profilehandler now callsresize(new_profile.connection_cap)and reflects this in the response (newconnection_capfield). Daemon-sideStratumV1CapResizeradapter withOnceLock<StratumServerHandle>for late binding (AppState is built in boot before the Stratum server). -
28.3 SSE auto-reconnect with exponential backoff (THIS SESSION) — ui/src/lib/events.svelte.ts completely re-architected. Before: primitive 5s-fixed timeout that fired only on
readyState=CLOSED, browser auto-retry in parallel = duplicate reconnect attempts. Now: ononerrorthe socket is explicitly closed and a custom reconnect via exponential backoff (BACKOFF_MS = [1s, 2s, 4s, 8s, 16s, 30s]) is scheduled. Counterstate.reconnectAttempts(UI-readable for "reconnecting…" banner display), reset to 0 after successfulonopen. Pure helperbackoffMsForAttempt(attempt)as named export for unit tests + UI display.stop()cancels pending timer. -
28.4 Tests + docs (THIS SESSION) — +7 tests: config (3): retention_defaults_match_historical_hardcoded_values / retention_parses_from_toml_with_custom_values / retention_partial_block_merges_with_defaults; stratum-v1-server (4): handle_connection_cap_returns_initial_max / resize_increase_adds_permits_immediately / resize_same_value_is_noop / resize_decrease_consumes_permits_when_available. Existing
#[cfg(test)]block in stratum-v1::server needed#[allow(clippy::field_reassign_with_default)]analogous to the other test modules (Phase 24 pattern). 541 tests green (+7 vs 534 after Phase 27), svelte-check 298 files / 0 errors / 0 warnings.
Phase 27 — UPnP renew loop (complete)
-
27.1 UpnpConfig + daemon renew loop (THIS SESSION) — Phase 11 implemented UPnP add/remove in the setup binary with default lease 3600s. Without periodic renew, the lease expires after 1h and the pool is no longer reachable from outside. New
[upnp]block in crates/config/src/lib.rs withUpnpConfig { enabled, renew_interval_secs, forwards: Vec<UpnpForwardSpec> }; eachUpnpForwardSpechasport,protocol(tcp/udp),lease_seconds,description+ avalidate()fn that checks port>0, protocol∈{tcp,udp} (case-insensitive), lease∈60..=86400. Daemon-side (apps/dvb-warppool-daemon/src/main.rs):upnp_renew_loopas tokio task; per tickspawn_blocking(run_upnp_renew_once)which callsigd::search_gatewayonce + thenadd_portper spec (idempotent on the router side). Pure-dataUpnpRenewReport { ok, failed, last_error }so the loop and the blocking helper are separately testable.clamp_upnp_intervalclamps to [60s, 24h]. Initial tick 10s after daemon start (crash-recovery case). Errors are only logged — no loop exit on transient errors.igd = "0.12"as new dep in the daemon Cargo.toml (same version as setup binary, no Cargo-lockfile duplicate). - 27.2 Tests + docs (THIS SESSION) — +7 tests: config (3): default-disabled-empty / forward-spec-validate-port-protocol-lease / parses-from-toml-with-multiple-forwards; daemon (4): clamp_upnp_interval_below_60 / above_24h / passes_through_in_range / run_upnp_renew_once_with_empty_specs_returns_empty_report. docs/book/src/health-checks.md Phase-27 section extended (TOML example, lease-vs-interval tip, disable path), Phase-11 "no auto-renew" bullet reworded to "see Phase 27". 534 Rust tests (+7 vs 527 after Phase 7.7), clippy --all-features -D warnings clean.
Phase 7.7 — Reorg edge cases + stale-share detection (complete)
-
7.7.1 Sv2 server stale-job-id detection (THIS SESSION) —
precheck_submitin stratum-v2/src/session.rs:356 extended with job_id vs channel.current_job_id check. Two new error paths:current_job_id == None(no job broadcast on this channel yet) →"job-not-found"frame;current_job_id == Some(x)butshare.job_id != x(miner submits shares for outdated job after reorg) →"stale-share"frame. Previously, both paths went through to the validator and were judged as LowDifficulty / Valid depending on PoW — semantically wrong, since shares with outdated job_id may not count even with correct PoW. Wiring: New methodMiningServerSession::record_job_broadcast(job_id)called afterbuild_job_frames(server loop inhandle_connection), iterates the ChannelRegistry and setsch.current_job_id. Previously thecurrent_job_idfield was in state but never written. Migration: Existing 3 tests (full_session_setup_open_submit_e2e,duplicate_sequence_number_returns_error,submit_extended_share_returns_success) needed arecord_job_broadcast(0)between OpenChannel and Submit — otherwise they would now correctly get"job-not-found". Translator E2E (end_to_end_v1_miner_through_translator_to_sv2_server) needed an additionalpush_job+ drain of the resultingmining.notifyline beforemining.submit. -
7.7.2 Translator pending_submits bounded cleanup (THIS SESSION) —
pending_submits: HashMap<u32, serde_json::Value>in translator/src/connection.rs:160 was unbounded → DoS vector on pool crash / network split (pool never answers → map grows per submit). New type signatureHashMap<u32, (serde_json::Value, Instant)>with insertion timestamp. Constants:MAX_PENDING_SUBMITS = 1024(covers ~10s burst at 100 shares/s),PENDING_SUBMIT_TTL = 30s(typical pool roundtrip is <1s, 30× safety margin). New helper fnprune_pending_submitsis called before every insert: first TTL pruning (single-pass O(n) retain), then — if still above cap — oldest-by-timestamp eviction. Both remove sites inSubmitSharesSuccess/SubmitSharesErrorhandlers now destructure the tuple. +3 tests:prune_drops_expired_entries,prune_enforces_max_size_by_dropping_oldest,prune_on_empty_map_is_noop. -
7.7.3 +4 Sv2 server stale-detection tests (THIS SESSION) —
submit_before_any_job_broadcast_returns_job_not_found,submit_with_stale_job_id_returns_stale_share,submit_with_matching_job_id_succeeds,record_job_broadcast_updates_all_channels(verifies that both channel kinds — Standard + Extended — get the job_id with the same call). 527 tests green (+7 vs 520 after Phase 25), clippy --all-features -D warnings clean. - Note: The ROADMAP Phase 7.5 note "Standard-channel mining.notify (today the Standard job is emitted locally by the pool, translator uses Extended)" was re-evaluated and is NOT a real bug: Sv2 server already emits
NewMiningJob(0x1E) correctly for Standard channels viabuild_job_frames(stratum-v2/src/server.rs:323); translator usesOpenExtendedMiningChannelby design because Standard channels have no extranonce path — that's the right architecture, no polish needed.
Phase 25 — Logos/icons + brand polish (complete)
-
25.1 PWA icons in all sizes (THIS SESSION) —
dvb-WarpPool_Logo.png(1024×1024 RGBA with real transparency) as source. Generated via Pillow (not sips — sips serializes alpha channel as white) inui/static/icons/: 16/32/64/180/192/256/384/512 square PNGs with preserved transparency for PWApurpose: any. Additionallyicon-{192,256,384,512}-maskable.pngfor Android Adaptive Icons with 80% safe area + theme-color background#05060B(the logo scales to 80% and centers on a dark square so crops to circle/square/squircle don't cut off the content).ui/static/apple-touch-icon.png(180×180 as opaque RGB without alpha — iOS renders PNGs with transparency poorly);ui/static/favicon.ico(multi-resolution 16+32+48 for legacy browsers). -
25.2 manifest.webmanifest + app.html updates (THIS SESSION) —
manifest.webmanifesticonsarray extended with 4× any + 2× maskable PNG entries (favicon.svg remains primary for modern browsers with any-sized SVG support).app.htmlnew:<link rel="alternate icon" href=".../favicon.ico">+<link rel="apple-touch-icon">+ 3apple-mobile-web-app-*meta tags (capable=yes, status-bar-style=black-translucent, title=WarpPool); plus 4 OpenGraph tags (og:title/description/image/type) + Twitter card (summary_large_image) for social previews when the pool is publicly linked. -
25.3 Social-preview card (THIS SESSION) — Pillow-generated
docs/social-preview.png+ui/static/og-image.png(1280×640, OpenGraph standard ratio): logo on the left on a manual radial-gradient purple glow (#7B5CFF with alpha falloff), on the right "dvb-WarpPool" as 84pt Helvetica, "Bitcoin Solo Mining Pool" + "Stratum V1 + V2 · PWA · Modern UI" as subtitle, github.com/dvb-projekt/dvb-WarpPool as footer. Theme-consistent#05060Bbackground. -
25.4 README header with logo (THIS SESSION) —
README.mdgets a centered logo header (<p align="center"><img width=200>) + title as<h1 align="center">+ 1-line tagline + 4 Shields.io badges (License/Rust/Tests/Platforms). HTML tags so GitHub rendering centers the logo correctly (Markdown images are left-aligned). -
25.5 Build verification (THIS SESSION) —
npm run check: 298 files / 0 errors / 0 warnings.npm run build: all 12 PNG icons + apple-touch-icon.png + favicon.ico + og-image.png land inbuild/output (adapter-static copy-from-static). -
25.6 Header typography aligned to logo — rejected (THIS SESSION) — Attempt to render the header title via CSS
background-clip:textwith a vertical Gold→Orange→Bronze gradient was rolled back because the original wordmark of dvb-WarpPool as chrome image asset is significantly higher quality than any CSS approximation (silver-blue-gold with V-shape accent — see 25.7).--gradient-brandCSS var removed fromapp.css,.title+.dotoriginal styles restored. -
25.7 Wordmark as header brand asset (THIS SESSION) — Source
dvb-WarpPool Schriftzug.png(1536×1024 chrome wordmark with silver-dvb / blue-Warp / gold-Pool + V-shape accent + transparent BG) as new brand anchor. Python Pillow pipeline: alpha-threshold bbox finds real content bounds (1029×253) + 24px breathing room →ui/static/wordmark.png(858×240) +wordmark@3x.png(1288×360, retina).+layout.svelte<span class="dot">+<span class="title">replaced by<img class="wordmark" srcset="/wordmark.png 1x, /wordmark@3x.png 3x" width=143 height=40 alt={brand}>. CSS:.wordmark { height: 40px; filter: drop-shadow(0 2px 8px rgba(0,224,255,0.18)); }with hover transition to warp-purple glow. Mobile-responsive: 32px / 28px height steps. -
25.8 Favicon derived from logo (THIS SESSION) — Previous
favicon.svgwas an old abstract purple-cyan design with no logo relation. Newly drawn SVG (ui/static/favicon.svg) adopts the logo DNA: dark#05060Brounded-rect BG, orange Bitcoin rim ellipse (Gold→Orange→Bronze), below it blue-cyan vortex disk, black "wormhole" center, Bitcoin₿in the foreground (oversized 30pt so it stays readable at 16×16) with gold highlight + dark 0.8px stroke. Simplifies the hyper-realistic PNG logo to a flat-design marker that works at any tab size.
Phase 24 — Polish & cleanup (complete)
-
24.1 clippy --all-features clean (THIS SESSION) —
cargo clippy --workspace --all-targets --all-features -- -D warningsgreen. Fixed: 26×field_reassign_with_defaultannotated over test modules with#[allow(...)](6 test modules: config / api / notifier / stratum-v1::ratelimit + ::vardiff / job-builder); 2×manual_ignore_case_cmpin avalon-probe (.eq_ignore_ascii_case); 1×type_complexityin whatsminer-probe (5-tuple →type StatsExtract); 2×get_firstin stratum-v1::messages (.first()instead of.get(0)); 2×match_like_matches_macroin the daemon (debounce checks); 1×explicit_auto_derefin notifier metrics_snapshot; 1×useless_vecin webpush test (&[0u8; 16]); 1×expect_fun_callin telemetry; 1×non_snake_casetest name (cgminer_status_E_yields_error→_e_); 1×doc_lazy_continuationin hwdetect; 1×too_many_argumentsinjob_refresh_loopvia#[allow](central wiring wrapper, 10 args are essential). Benchcrates/job-builder/benches/build_job.rsrewritten directly toMiningConfig { ..Default::default() }pattern (not test code, therefore noallow). -
24.2 Stale TODO/FIXME inventory (THIS SESSION) — 7 TODO sites evaluated: 3× stale in CLI code (Profile hot-switch + NotifierTest were already implemented backend-side in Phase 3/15.5, CLI lagged behind) →
NotifierTestsubcommand re-implemented with realPOST /api/admin/notifier/testcall incl.sink=allspecial path and tabular ok/err output; SetProfile doc comment clarified. 2× in sim-binary stubs (Load/Failures) — replaced by Phase-3scenariosubcommands → clear exit-2 with pointer to existing command instead ofprintln!("TODO"). 1× in job-builder header doc comment ("TODO in share-validator") → obsolete, Reserved-Value appending is implemented in the daemon (attach_witness_reserved). 1× in sim-node::set_mempool → converted into a doc comment with rationale (the regtest path from Phase 15b is the test path today, SimNode reserved for future mocks).grep -rn "TODO\|FIXME\|XXX"over crates+apps+ui/src now empty. -
24.3 svelte-check & test suite (THIS SESSION) —
cargo test --workspace --all-featuresnow 520 passed / 0 failed / 3 ignored (vs. 514 in memory snapshot, +6 are doc tests that now run too). UI svelte-check: 298 files / 0 errors / 0 warnings (before: 1 pre-existing CSS warningth.numunused in /blocks → resolved by correctly marking the height-column header asclass="num", semantically consistent withtd.num). -
24.4 Mark ROADMAP Phase 2 items as complete (THIS SESSION) — Phase 2 still had 8×
[ ]placeholders despite full realization through Phase 2.1/2.5 + Phase 3. Switched to[x]with reference to the sub-phase in which they were actually implemented.
Phase 23 — Probe hardening (skipped)
-
23 Probe hardening (THIS SESSION skipped) — Probes are LAN-local with latency <100ms;
warppool_miner_probe_health{label,host,vendor}gauge (Phase 22.1) already covers failure detection;last_errorfield in/api/minersshows the reason. Per-vendor timeout + probe-latency histogram analogous to Phase 16.3 would bring marginal value but cost 2-3h of work. Coordinated with the user: skip. Re-eval if probe failures actually pile up in production.
Phase 22 — Vendor probe metrics (complete)
-
22.5 Braiins OS probe adapter (THIS SESSION) — Open-source firmware for Antminer S9/S17/S19 from Braiins (Slush Pool). New
Vendor::BraiinsOSenum variant + parse aliases ("braiins"|"braiins-os"|"bos"|"bos+"|"braiinsos"). mDNS hostname hint in HTTP fallback:braiins*/bos-*prefix maps to BraiinsOS, before the BitMain pattern (so that "braiins-antminer-s19" doesn't misclassify as stock BitMain). Newprobes/braiins.rswith own field-schema extension:power_consumption_w(Braiins exposes this, stock doesn't),voltagewith V→mV heuristic (< 100 = V),miner_version/bos_versionas firmware string. Default model "Antminer (Braiins OS)" when STATS has no Type field. Skippedtemp_avg/temp_maxkeys so the hottest chip comes out oftemp1..N. 6 unit tests (typical S19, voltage-in-mv passes through, default-model label, skips-temp-avg/max, string-valued numbers, parse aliases). api/lib.rs match arms for BraiinsOS in 2 places extended. troubleshooting.md vendor table extended. 514 tests green (+6 vs Phase 20.5). -
22.4 AvalonQ probe adapter (THIS SESSION) — Correction of the wrong memory entry "AvalonQ has no API". AvalonQ exposes the CGMiner socket on 4028 with its own field names (
THSspd=TH/s,TMax/TAvg,Cur_Load=W,Fan1-4,Accepted_Shares,Workmode). Sources:c7ph3r10/ha_avalonqHome Assistant template +gbechtel-beck/avalon-q-controller. NewVendor::AvalonQenum variant +parse("avalonq"|"avalon-q"|"avalon_q")+ display "Avalon Q (Canaan)". Newprobes/avalonq.rswithAvalonQVendor: CgminerVendor. Hashrate fromTHSspd(×1000 for GH/s) with fallback to SUMMARY fields. TemperatureTMaxpreferred, fallbackTAvg. Power fromCur_Load. Fan maximum fromFan1-Fan4(FanR=percent ignored!). Tolerant of string-valued numbers (some Canaan firmwares send numbers as strings). Default model "Avalon Q" when STATS has no Type field. 6 unit tests (typical-response / tavg-fallback / fanr-not-rpm / string-numbers / default-model / empty-stats). api/lib.rs match arms for AvalonQ in 2 places extended. 464 tests green (+6 vs 22.3). -
22.1 Per-miner Prometheus metrics (THIS SESSION) — Pure-function
miner_telemetry_metrics(&[MinerRecord], now) -> Stringrenders thelast_telemetry_jsonfields from theminerstable as 7 Prom metrics:warppool_miner_{hashrate_ghs,temperature_c,power_w,voltage_mv,fan_rpm}with labels{label,host,vendor,model}+warppool_miner_last_probe_age_seconds+warppool_miner_probe_health(1=OK&recent / 0=error|stale-after-5min). None fields are skipped instead of emitted as 0 so operator trends are not broken. HELP+TYPE per metric exactly once (also for N miners). Label escape for",\,\n. 7 new helper tests + 1 e2e test against the /metrics route. -
22.2 Discovered-miner auto-probing (THIS SESSION) — New
pub type DiscoveredTelemetryCache = Arc<RwLock<HashMap<String, MinerTelemetry>>>in warppool-telemetry.miner_poll_loopsignature extended withdiscovery_cache: Option<DiscoveryCache>,discovered_telemetry: Option<DiscoveredTelemetryCache>,auto_probe_discovered: bool. If envWARPPOOL_AUTO_PROBE_DISCOVERED=true|1|yesis set: per tick additionally probe all mDNS finds, telemetry lands in the shared map (no DB write — discovered hosts are ephemeral).AppState.discovered_telemetry: Option<DiscoveredTelemetryCache>added;/metricsrenders them withlabel="discovered"so PromQL can separate. 2 helper tests + 1 e2e test. -
22.3 Tests + docs (THIS SESSION) —
docs/book/src/observability.mdextended with Phase-22 section with 7-metric table, 4 PromQL examples (sum-hashrate, max-temp, efficiency, probe_health==0), separation discovered vs configured.docs/book/src/configuration-reference.mdextended withWARPPOOL_AUTO_PROBE_DISCOVEREDenv var. 458 tests / 0 failed / 3 ignored (+11 vs Phase 19: 7 unit helper tests + 4 e2e in api).
Phase 19 — Performance benchmarks (complete)
-
19.1 share-validator/benches/validate.rs (THIS SESSION) —
ShareValidator::validate()with 0/8/12 merkle branches plus micro-benches for sha256d (80b header / 500b coinbase), compute_merkle_root (0/4/8/12 depth), reconstruct_coinbase, build_header. Baseline on M-series: validate_full/12=7.59µs (132K shares/s), sha256d_80b=528ns.harness = false+ criterion 0.5 withcargo_bench_supportfeature (no gnuplot/html_reports default). -
19.2 job-builder/benches/build_job.rs (THIS SESSION) —
JobBuilder::build()with 0/100/1000/4000 tx counts + compute_merkle_branches isolated. Baseline: build_job/4000=2.59ms (386 jobs/s), merkle_branches/4000=2.30ms (dominates). -
19.3 stratum-v1/benches/vardiff.rs (THIS SESSION) —
VarDiff::observe_share()hold path (5.2ns/share) and retarget path (~5ns/share avg over 8-share sequence), difficulty_to_target_be (12.85ns), decision_variant_match (432ps). Translator bench deliberately omitted because build_v1_notify is per-job (every 30-60s), not per-share. -
19.4 CI workflow + doc (THIS SESSION) —
.github/workflows/benches.yml— manual-dispatch + tag-push, not PR trigger (Criterion+GitHub-runner noise unreliable); artifactcriterion-reports-<sha>with 30d retention.docs/book/src/benchmarks.mdwith suite overview, baseline tables, interpretation ("Pool CPU load? 0.0076% at 10 shares/s"), regression workflow, what-we-deliberately-didn't-bench.
Phase 18 — mdBook operator guide expansion (complete)
-
18 Operator pages (THIS SESSION) — Four new mdBook pages:
notifications.md(171 lines) — reference for ntfy/Telegram/Discord/Slack/Email + test workflow.observability.md(207 lines) — Prometheus metrics reference incl. Phase-16 histogram + Grafana JSON + 4 alert recipes.troubleshooting.md(322 lines) — symptom→diagnosis→fix for RPC/IBD/ZMQ/worker-loops/notifier/auto-update/setup/DB-rebuild.configuration-reference.md(291 lines) — complete config.toml + secrets.toml + env vars + CLI override. SUMMARY.md extended; cross-refs in getting-started.md + health-checks.md + auto-update.md. -
18.1 ARCHITECTURE.md + SECURITY.md refresh (THIS SESSION) — The two
{{#include}}stub sources written before the Sv2 stack + Phase-3 auth + Phase-15 notifier were completely rewritten. ARCHITECTURE.md (98→401 lines): 16 crates in 5 categories, daemon task topology, shared-state table, storage-schema table (10 tables), Sv2 stack detail, connection-lifecycle hook pattern, event bus + SSE list, boot lifecycle in 21 steps. SECURITY.md (33→279 lines): 4-actor threat model, 19-row threat-mitigation matrix with phase references, auth-stack walkthrough (JWT/API tokens/2FA-TOTP), key-material table with leak consequences, audit-log inventory, TLS layers, reproducible builds + Cosign workflow, Stratum hardening, "what is deliberately not implemented".
Phase 16 — Observability extension (complete)
-
16.1 Notifier counter (THIS SESSION) —
Notifierstruct getscounters: tokio::sync::Mutex<HashMap<(sink, event_kind, result), u64>>.inc_countercalled fromnotify()+test_sinks().metrics_snapshot()returnsVec<NotifierMetric>for /metrics handler. Per-sink view on send successes/failures. -
16.2 Worker lifecycle metrics (THIS SESSION) — New
crates/telemetry/src/metrics.rswithPoolMetricsstruct:AtomicU64/AtomicI64counters forworkers_authorized_total,workers_disconnected_total,active_connections_{v1,v2}.NotifierConnectionSinkgetsmetrics: Option<Arc<PoolMetrics>>+protocol: &'static str("v1"|"v2"), increments per on_authorized/on_disconnect. -
16.3 RPC latency histogram (THIS SESSION) —
BitcoinRpc.metrics: Option<Arc<PoolMetrics>>+ builderwith_metrics(arc).BitcoinRpc::callwrapped inInstant::now()measurement for total elapsed time (incl. all retries) →pool_metrics.record_rpc_latency(secs). Buckets[0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, +Inf](Prometheus cumulative semantics). Innercall_with_retryis the old logic moved. -
/metrics handler in
warppool-apiextended with 6 new Prom metrics (when pool_metrics/notifier set):warppool_workers_authorized_total(counter),warppool_workers_disconnected_total(counter),warppool_active_connections{protocol="v1|v2"}(gauge),warppool_bitcoin_rpc_latency_seconds(histogram),warppool_notifier_events_sent_total{sink,event,result}(counter),warppool_notifier_sinks_active(gauge). Opt-out by default: without pool_metrics, /metrics renders only the base counter. -
AppState gets
pool_metrics: Option<Arc<PoolMetrics>>field, default None in test builders. +8 tests (5 telemetry + 3 api). 447/447 tests green.
Phase 15.2b — v2 connection notifier wiring (complete)
-
15.2b Sv2 connection-lifecycle hooks (THIS SESSION) — Analogous to Phase 15.2 for stratum-v2. New
pub trait ConnectionSinkincrates/stratum-v2/src/session.rswith signature identical to v1.Sv2ServerConfig.connection_sink: Option<SharedConnectionSink>.Sv2Server::with_connection_sink(...)as new ctor. handle_connection wraps the loop inasync {...}.awaitso on_disconnect fires on both paths (Ok-exit + Err-exit).notified_users: HashSet<String>tracks seenuser_identitystrings; after eachsession.process_frame, iteratesession.channels(), every NEW user_identity fireson_authorized(spawned). On loop exit:on_disconnectfor every seen user. A v2 connection can open multiple channels with different identities — all are correctly notified. Daemon builds two NotifierConnectionSink instances (v1 + v2) each with the same notifier + debounce setting. NotifierConnectionSink impl both traits (v1+v2) on the same struct via separate impl blocks. Sharedhandle_disconnectmethod factors out the debounce+notify logic. async-trait added as new dependency in stratum-v2. 71/71 v2 tests green, all existing tests green.
Phase 15 — Notifier fully wired + Email/Slack + Admin UI (complete)
-
15.1 RpcDown/Recovered in the daemon (THIS SESSION) — Health loop in daemon main.rs tracks
prev_rpc_ok: Option<bool>+down_since: Option<Instant>; on (Some(true)|None, false) fireNotifyEvent::RpcDown {duration_secs:0}, on (Some(false), true) fireRpcRecoveredwithdown_since.elapsed().as_secs()as debug log. Inline 4-arm match. -
15.2 MinerDisconnect (V1) (THIS SESSION) — New
ConnectionSinktrait inwarppool-stratum-v1analogous toShareSink:on_authorized(worker, peer)+on_disconnect(worker, peer).SessionRunArgsgetsconnection_sink: Option<SharedConnectionSink>,Session::runfires hooks in spawned task. Daemon adapterNotifierConnectionSink {notifier, debounce, last_notified: Mutex<HashMap<String,Instant>>}with env-configurableWARPPOOL_DISCONNECT_DEBOUNCE_SECS(default 30). -
15.3 Email sink (THIS SESSION) —
lettre = "0.11"workspace dep withtokio1-rustls-tls + smtp-transport + builder + hostname + ringfeatures (rustls instead of openssl-sys).EmailSinkparsessmtps://user@host:465(implicit TLS) orsmtp://user@host:587(STARTTLS); auth viapassword_envconfig field. Extended withon_miner_disconnect+on_rpc_downtoggles. 7 new tests. - 15.4 Slack sink (THIS SESSION) — Webhook with Block Kit payload (header + section with mrkdwn). Per-event emoji. 4 new tests.
-
15.5 API endpoints (THIS SESSION) —
AppState.notifier: Option<Arc<Notifier>>. NewNotifier::test_sinks(filter) -> Vec<SinkTestResult>: 1 attempt (no retry backoff). 2 new routes:GET /api/admin/notifier/sinks+POST /api/admin/notifier/test[?sink=<name>]. Audit lognotifier.test. 6 new API tests. -
15.6 UI admin section (THIS SESSION) —
src/lib/api.tsadmin.notifier.{sinks, test}client methods. New BentoCard "Server-Side Sinks (Daemon)" with per-item test buttons + badge state (neutral/ok/err) + last-error tooltip + "Test all sinks" primary. svelte-check 0 errors, 1 pre-existing CSS warning unchanged. -
15b Regtest E2E scaffold (THIS SESSION) —
scripts/regtest-up.sh(bitcoind regtest with RPC+ZMQ-hashblock+ZMQ-rawtx, configurable via env vars, idempotent via PID file, KEY=value env output foreval) +scripts/regtest-down.sh(clean stop + optional--purge).crates/bitcoin-rpc/tests/regtest_e2e.rswith 3#[ignore]tests (regtest_blockchain_info_returns_regtest_chain, getblocktemplate_works, submit_invalid_block_is_rejected). Locally:eval "$(scripts/regtest-up.sh)"; cargo test ... -- --ignored; scripts/regtest-down.sh --purge. 439/439 Rust tests + 3 ignored.
Phase 14 — UI HealthBanner (complete)
-
14 SSE events → dashboard banner (THIS SESSION) — Closes the backend-push story from Phase 8e (
update_available) + 13b (health_snapshot): until now the daemon pushed the events, the UI ignored them. Now a newHealthBanner.sveltecomponent renders them as a persistent banner below the header (visible on all pages via +layout.svelte). TypeScript types (ui/src/lib/types.ts):HealthSnapshotEvent+UpdateAvailableEventinterfaces, extended into PoolEvent union.PoolEventTypederived type automatically gets both. events.svelte.tsALL_TYPESarray extended with 'update_available' + 'health_snapshot' so EventSource registers the named events. Component behavior:lastHealth+lastUpdatestate, both separately dismissable per session (no localStorage — after tab reload they come back).maybeRevivehelper: if new warnings appear that weren't there before, dismissed flag is reset (operator sees state deterioration). Update banner: dismissed flag reset on new latest tag. Health banner: yellow-orange border + ⚠️ icon, lists warnings as bullet list + meta row (peers/zmq/ibd). Update banner: cyan-magenta gradient + ⬆️ icon, current→latest, pre-release badge (red) if prerelease=true, link to /admin. i18n keys in all 8 locales (de/en/es/fr/it/ja/pt-BR/zh) for healthBanner.* + updateBanner.* (en/de hand-crafted, remaining 6 with English fallback via Python script). svelte-check 294 files / 0 errors / 1 pre-existing warning (CSS unused selector in blocks/+page, not my code). 423/423 Rust tests green (no Rust-side change in 14). ROADMAP "Phase 14 — UI HealthBanner" section.
Phase 13b — Daemon periodic health (complete)
-
13b Periodic re-check (THIS SESSION) — Daemon uses
warppool-healthcrate (Phase 13a) for continuous Bitcoin Core diagnostics in the running pool. env varWARPPOOL_HEALTH_CHECK_INTERVAL_SECONDS(default 60, 0=off) gates the tokio task. Per tick: RPC auth re-resolved from config.node.rpc_cookie_path preferred + fallback to secrets.rpc_user/pass (cookie is rotated on Bitcoin restart, so always fresh each time);check_bitcoin_health+collect_bitcoin_warnings; publish newPoolEvent::HealthSnapshot{at, rpc_ok, peers_total, peers_inbound, ibd, pruned, zmq_hashblock_ok, warnings[]}variant in event_bus. Respects cancellation token. 2 new tests (health_snapshot serialize with snake_case tag + warnings array; round_trips_through_bus). event_type_name helper extended with "health_snapshot" variant. 423/423 tests green (+2 vs 13a). docs/book/src/health-checks.md Phase-13 section with wiring description, SSE event format JSON example, what-NOT-13b (no bitcoin.conf parse, no UPnP, no delta detection, no /api/admin/health endpoint), operator curl example for live watch, Phase-14 outlook (UI dashboard widget, delta detection, REST endpoint).
Phase 13a — warppool-health crate (complete)
-
13a Crate extraction (THIS SESSION) — Pure refactor. New crate
crates/health/with 3 modules:rpc.rs(RpcAuth + RpcError + rpc_call + resolve_rpc_auth + 4 tests),bitcoin.rs(BitcoinHealth + PeerStats + ZmqEndpoints + check_bitcoin_health async + collect_bitcoin_warnings pure-fn + 7 tests),conf.rs(ParsedConf + parse_bitcoin_conf_str + generate_bitcoinconf_snippet + 13 tests incl. parser edge cases + filter logic). Setup wizard refactored to a thin axum handler wrapper (~60-linebitcoin_health()instead of ~300+ lines previously) that calls into the crate + wraps setup-specific tilde-expand in front of it. Setup wizard loses 25 health tests that now live in the crate (cleanly consolidated; 1 test was redundant — net -1 vs 422). Foundation for Phase 13b (daemon periodic-health-check task) which uses the crate without code duplication. 421/421 tests green. clippy clean (1 pre-existingio::Error::new(Other,...)→io::Error::other(...)modernization fixed).
Phase 12 — bitcoin.conf parse-existing (complete)
-
12 parse + filter (THIS SESSION) —
parse_bitcoin_conf_str(&str) -> BTreeMap<String, Vec<String>>as line-based tolerant parser (comments, empty lines, section headers[main]/[test]ignored, multiple values per key, malformed lines tolerant).BitcoinHealthReqgets optionalbitcoin_conf_path: String.bitcoin_health()handler reads the file when path is set; if parse result is empty (file not readable) → warning but health check continues.generate_bitcoinconf_snippet(&BitcoinHealth, Option<&ParsedConf>)filters recommendations per key (already_has("zmqpubhashblock") → skip). Default behavior remains: without path arg → all recommendations as before. UI in the Bitcoin card: optional input field "bitcoin.conf path (optional, for smarter snippets)" with OS-aware placeholder + hint text. Path is sent with /api/bitcoin-health. 10 new tests (parser: empty+comments, basic, multiple-values, section-headers, whitespace, malformed-lines; filter: skip-zmq-when-configured, skip-listen, skip-maxconnections, no-filter-when-no-parsed). 422/422 tests green (+10 vs 11). docs/book/src/health-checks.md Phase-12 section with workflow, before/after example, what-we-don't-do list (no value compare, no direct write, no TOML parse), Phase-13 outlook.
Phase 11 — UPnP port forwarding (complete)
-
11 UPnP add/remove via igd (THIS SESSION) — POST /api/network-upnp-forward + POST /api/network-upnp-remove. Port whitelist {8333, 3333, 3334, 34254} so it can't be misused as a generic port opener. Protocol whitelist {tcp, udp}. Consent gate → 422 without
consent=true(router state change is invasive). Default lease 3600s (1h), hard cap 86400s (24h). Local IPv4 detection via UDP-connect trick (bind 0.0.0.0:0 + connect 8.8.8.8:80 + local_addr) — no extra dep. IPv6 outbound → error (UPnP requires IPv4 NAT). spawn_blocking because igd is sync. UI in the Network card: per Stratum port + Bitcoin P2P, one Forward+Remove button + global consent toggle. Success feedback shows local_ip + lease_seconds + gateway. 8 new tests (consent-422, wrong-port-400, wrong-protocol-400, remove without consent, remove wrong-port, allowed-ports-list-exact, default-lease-1h, parse_protocol case-insensitive). 412/412 tests green (+8 vs 10a). Live tested against FritzBox: discover OK, add_port refused with "client not authorized" (FritzBox default — UPnP state changes must be separately enabled); backend responds cleanly with HTTP 502 + clear error message. docs/book/src/health-checks.md extended with Phase-11 section: endpoints, security-gates table, what-we-don't-do list, typical router quirks (FritzBox/OpenWrt/Telekom-Speedport), Phase-12 outlook (periodic re-check, bitcoin.conf parse-existing, UPnP renew loop).
Phase 10 — bitcoin.conf snippet generator (complete)
-
10a Snippet generator (THIS SESSION) — Pure function
generate_bitcoinconf_snippet(&BitcoinHealth) -> Option<String>maps warnings to concrete bitcoin.conf lines: ZMQ-missing→pubhashblock+pubrawblock, low-peers→maxconnections=125, no-inbound-mainnet→listen=1. Wrapped in# ===== dvb-WarpPool Recommendations =====header/footer so the operator copy-pastes without shadowing existing settings.BitcoinHealthgetsrecommended_conf_snippet: Option<String>field. UI: 📋 box with copy button (navigator.clipboard.writeText) + OS-aware path hint (Linux/macOS/Windows via navigator.platform) + fallback alert when clipboard API is not available. 6 unit tests (healthy-no-snippet, zmq-missing, rawblock-existing-only-hashblock, listen-mainnet-not-testnet, maxconnections-trigger, 8-outbound-no-maxconnections). 404/404 tests green (+6 vs 9). docs/book/src/health-checks.md extended with snippet section + Phase-11 outlook (periodic re-check in the daemon, bitcoin.conf parse-existing for missing-lines-only, UPnP port mapping).
Phase 9 — Setup health checks (complete)
-
9a Bitcoin multi-RPC health check (THIS SESSION) — 5 RPC calls (getnetworkinfo, getblockchaininfo, getindexinfo, getpeerinfo, getzmqnotifications) in parallel via tokio::join!. Aggregated in BitcoinHealth struct with pure
collect_bitcoin_warnings()helper. Warnings: IBD in progress, low peers, no inbound (only mainnet), no zmq-hashblock, pruned, low verification progress (excl. when IBD). 8 unit tests for all edge cases. -
9b Local network setup (THIS SESSION) — Port-bind smoke for 3333/3334/34254 (TcpListener::bind+drop), UPnP discovery via
igdcrate (pure-rust) in spawn_blocking wrapper. Returns NetworkHealthLocal with ports[] + upnp{gateway, external_ip} + warnings. Live tested against FritzBox. -
9b External probes with consent (THIS SESSION) — POST /api/external-probe with body {probe, consent, public_ip?}. 422 if consent != true. Three probe types: ip_echo (api.ipify.org), bitnodes_8333 (bitnodes.io/api), stratum_port_guide (returns only 3rd-party tool URLs, contacts NOTHING itself).
contactedfield in response for audit transparency. - 9 UI (THIS SESSION) — Health card with RPC call result + warnings list; Network card with port status + UPnP + 3 separately-consented probe rows (checkbox + inline explanation + red 🛡 warn badge). Caching of external IP between UPnP/IP-echo → bitnodes probe without extra server call.
- 9 Tests — 10 new tests (8 warnings logic + consent gate + unknown probe rejection + stratum-guide no-http-calls). 398/398 tests green (+10 vs 8g).
-
Docs — New mdBook chapter
health-checks.mdwith privacy matrix + warnings explanation + Phase-10 outlook (periodic re-check in the daemon, bitcoin.conf auto-write, UPnP port mapping).
Phase 8 — Polishing (complete 8a-8g)
-
8b Reproducible builds (THIS SESSION) — Cargo
[profile.release]switched tolto = "fat"+ explicitincremental = falsefor deterministic LTO without thin-parallelism drift. New .github/workflows/repro.yml buildsdvb-warppool-daemonin two matrix jobs (a/b) on two fresh Linux x86_64 runners with identicalSOURCE_DATE_EPOCHfrom commit timestamp andRUSTFLAGS=--remap-path-prefix=<workspace>=/repo + cargo-home=/cargo/registry + cargo-git=/cargo/git; diff job compares sha256, fails on drift. scripts/verify-reproducible.sh as end-user verify tool: builds locally with same flags + downloads release asset from GitHub + sha256 compare. New mdBook chapter docs/book/src/reproducible.md with Why/How/Limitations/Debug-Workflow. SLSA-3 provenance viaslsa-github-generatorhas been in release.yml since Phase 6 — this 8b validates that the build is deterministic (which SLSA provenance does NOT guarantee, only the origin). -
Cosign signatures (partly in Phase 6 —
cosign sign-blobkeyless OIDC for SHA256SUMS already set up) -
SBOM (Syft) (partly in Phase 6 —
anchore/sbom-actionalready in the release) -
8c Auto-update foundation (THIS SESSION) — New crate
warppool-autoupdatewith 4 modules:version(Semver-subset parser incl. pre-release ordering),release(minimal GitHub API client via reqwest+serde, no octocrab),download(streaming download with SHA-256 verify; mismatch → file is deleted),swap(atomic_swap(new, current, backup_to)via POSIX rename + chmod 0755 + optional backup slot). Two new CLI subcommands indvb-warppool-cli:check-update [--repo X](HTTP fetch + version compare against CARGO_PKG_VERSION + asset list;--jsonfor scripting),download-update --sha256 X --to PATH [--asset Y](auto-asset selection viahost_asset_substring()for linux-x86_64/-aarch64/macos-x86_64/-aarch64/windows-x86_64, otherwise manual--assetoverride; no atomic swap — operator does it manually for auditability). mdBook chapter auto-update.md with MVP flow + operator bash script example incl. cosign verify + Phase-8d outlook (atomic swap on running daemon + rollback logic + integrated cosign). +25 tests (24 lib + 1 doctest in autoupdate). 381/381 tests green (vs 356 before 8c). -
8d Auto-update API wiring (THIS SESSION) — AppState gets
update_checker: Option<Arc<UpdateChecker>>. Two new admin-protected endpoints inwarppool-api:GET /api/admin/update-check(HTTP fetch latest + compare against CARGO_PKG_VERSION; response incl. assets + newer flag; auditupdate.check),POST /api/admin/update {asset, sha256, target_path, backup_path?}(download_verified + atomic_swap from inside the daemon process; no restart —restart_hintfield showssystemctl restart dvb-warppool; auditupdate.applied/update.failedwith details). Both 503 when checker=None. Daemon readsWARPPOOL_AUTOUPDATE_REPOenv var on startup; set →Arc<UpdateChecker>injected, otherwise None (deliberately not in config.toml because shell-access equivalent).UpdateChecker::with_api_base()builder new for tests against a local mock server. 4 new API tests:update_check_without_checker_returns_503,update_check_without_auth_returns_401,update_check_returns_latest_and_newer(against axum mock GitHub server),update_apply_without_checker_returns_503. 385/385 tests green (+4 vs 8c). docs/book/src/auto-update.md extended with Phase-8d API section incl. curl examples + systemd activation. Phase-8e as outlook (automatic restart via systemd D-Bus, OnFailure rollback, integrated cosign, periodic SSEupdate_availableevent). -
8e Periodic auto-check + SSE event (THIS SESSION) — New PoolEvent variant
UpdateAvailable{at, current, latest, name, prerelease}with snake_case type tagupdate_available. Daemon spawns periodic background task whenWARPPOOL_AUTOUPDATE_REPOANDWARPPOOL_AUTOUPDATE_INTERVAL_HOURS > 0(default 24, 0=disabled): every N hoursfetch_latest+is_newer_than(CARGO_PKG_VERSION)→ on newer releasebus.publish(UpdateAvailable). Initial delay 60s against GitHub API burst on boot. API errors (rate limit, network) → warn log, retry in the next interval (24h interval = ~2 req/day, far below the 60-req/h anonymous limit). 2 new tests (update_available_serializes_with_snake_case_tag,update_available_round_trips_through_bus). docs/book/src/auto-update.md extended with Phase-8e section incl. SSE event schema and JS EventSource example for UI banner. -
8f Systemd OnFailure rollback (THIS SESSION) —
dvb-warppool.serviceextended withStartLimitInterval=300s+StartLimitBurst=4+OnFailure=dvb-warppool-rollback.service(otherwise Restart=on-failure would loop endlessly without triggering OnFailure). New files:packaging/systemd/dvb-warppool-rollback.service(Type=oneshot, User=root, ConditionPathExists for/usr/lib/dvb-warppool/rollback.sh, no restart loop) +packaging/systemd/rollback.sh(portable bash, BSD+GNU compatible — usesls -1tinstead of GNU-onlyfind -printf): finds newest backup in$WARPPOOL_BACKUP_DIR(default /var/lib/dvb-warppool/backup), atomic install viainstall -m 755+mv -f(chown implicit since running as root), moves consumed backup to.applied-<timestamp>(prevents restart loop),systemctl restart --no-blockso no deadlock. Idempotent: no backup → warn + exit 0 (OnFailure chain doesn't spin). Override vars: WARPPOOL_BACKUP_DIR / WARPPOOL_TARGET_BIN / WARPPOOL_SERVICE. Mock-tested locally (mock-systemctl in PATH + tmp dirs for backup/target): newest backup wins, target gets replaced, backup gets renamed, systemctl called with restart. Missing-backup path also tested (exit 0 + warn log). docs/book/src/auto-update.md extended with Phase-8f section incl. flow diagram + configuration + local test snippet. -
8g Cosign verify integrated (THIS SESSION) — External cosign CLI invocation as tokio::process::Command before sha256 verify in POST /api/admin/update. Body extended with
cosign_verify: bool+cosign_args: Vec<String>(operator-controlled, server only appends the downloaded file as the last arg). Env varWARPPOOL_COSIGN_BINmust be set whencosign_verify=true— otherwise 500 (NOT silent skip, so the operator doesn't get false security). Cosign exit≠0 → 403 + auditupdate.failed/cosign verify-blob failed: exit N+ downloaded file is deleted. Rationale for subprocess instead of pure-rust sigstore: 30+ transitive deps saved, transparent (operator sees the exact cosign command), current (sigstore updates separately). 1 new test (update_apply_with_cosign_verify_but_no_env_returns_500) — fired against a real axum mock-asset server + mock GitHub API, env var explicitly cleared so deterministic. 388/388 tests green (+1 vs 8f). docs/book/src/auto-update.md Phase-8g section with complete operator example incl. real GitHub release asset URLs + sigstore OIDC issuer + identity regex. -
8a Documentation site (THIS SESSION) — mdBook under docs/book/ consolidates all existing Markdown files (ARCHITECTURE / PACKAGING / SECURITY / TESTING / UI-DESIGN / ROADMAP) via
{{#include}}directive plus three newly written chapters: Introduction (project pitch + status table), Getting Started (Docker / Native / Source build paths + Stratum V1/V2 listener overview + first-block walkthrough), Phase history (curated narrative for Sv2 7.1 → 7.6a with test counts and implementation details). CI: .github/workflows/docs.yml installs mdbook release binary (cached between runs), builds on push-to-main + tags + manual-dispatch, deploys viaactions/deploy-pages@v4to github-pages. PR builds run smoke check (index.html + roadmap.html + phases.html exist) without deploy.docs/book/out/in.gitignore(build artifact, never committed). Smoke test locally:mdbook v0.4.40 build docs/bookruns cleanly, all 9 includes resolve.