Introduction
dvb-WarpPool is a Rust Bitcoin solo mining pool, written as a modern parallel build to dvb-goPool. Run it privately (mine to your own address) or as a public, non-custodial 0% pool where every visiting miner mines to their own address (per-worker payouts) — either way the finder is paid directly on-chain. See the Public 0% Solo Pool guide. 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), 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, operator marquee announcement banner (Admin → Banner, since v1.21.0), 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.24.0 (2026-07-24) —
the releases page
always carries the current version.
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 template wiring (phase 7.6b) | ✅ opt-in Core IPC template source (v1.16.0); Sv2 TDP wire path still deferred |
| 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 by design — solo pays out through the block itself: the coinbase pays the miner address directly, with optional pool-fee / donation coinbase splits (UI-configurable since v1.0.17, Admin → Mining). A custodial payout ledger (pool wallet + PPLNS/FPPS batching) stays out of OSS scope — see Scaling |
| 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 (12): Bitaxe / BitForge Nano / NerdMiner / NerdAxe Gamma / NerdQaxe / 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.24.0): 1201 Rust tests green + 4 ignored (regtest opt-in). 374 svelte-check files / 0 errors / 0 warnings.
Workspace: 18 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 — the Sv2 TDP wire path (Option A) is still deferred; the modern template-acquisition goal itself shipped in v1.16.0 as the opt-in Core IPC template source (Option B)
Who builds the pool
dvb-WarpPool is a solo project by dvb-Projekt (dvbprojekt@gmx.de). Code is dual-licensed: AGPL-3.0-or-later for everyone, plus a commercial license for proprietary use (see COMMERCIAL-LICENSE.md). Contributions welcome — see CONTRIBUTING.md for the licensing terms.
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: git.warppool.org/dvb-projekt/dvb-WarpPool
- Documentation: docs.warppool.org
- Public reference instance: pool.warppool.org — non-custodial per-worker mode, 0% fee
- 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. GBT is the default
template source. Bitcoin Core ≥31 additionally offers an experimental
Cap’n-Proto IPC interface, which the pool can use as an opt-in alternative
template source ([node].template_source = "ipc") for a faster template step —
see Configuration Reference.
Docker
docker run -d \
--name warppool \
--restart unless-stopped \
-p 18334:18334 \
-p 3333:3333 \
-v ~/.warppool:/data \
git.warppool.org/dvb-projekt/dvb-warppool:latest
Add -p 3334:3334 (V1 TLS) and -p 34254:34254 (Stratum V2) if you enable
those listeners.
The container’s default entrypoint is the daemon, so the pool UI comes up at
http://localhost:18334. To run the first-run wizard (auto-detects hardware,
suggests a profile, tests the Bitcoin RPC) instead, start the container with the
wizard binary:
docker run --rm -it -p 8331:8331 -v ~/.warppool:/data \
git.warppool.org/dvb-projekt/dvb-warppool:latest \
dvb-warppool-setup --bind 0.0.0.0:8331
Native Binary
Raspberry Pi 5 (aarch64)
# arm64 .deb
wget https://git.warppool.org/dvb-projekt/dvb-WarpPool/releases/download/v1.24.0/dvb-warppool_1.24.0-1_arm64.deb
sudo dpkg -i dvb-warppool_1.24.0-1_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 releases page.
The macOS .dmg is not signed with an Apple Developer ID and not notarized
(that is on the roadmap as phase B), so Gatekeeper flags the download. Strip the
quarantine attribute from the .dmg and from the installed app:
xattr -dr com.apple.quarantine ~/Downloads/dvb-WarpPool-v*.dmg
# …mount, drag to /Applications, then:
xattr -dr com.apple.quarantine /Applications/dvb-WarpPool.app
From Source
git clone https://git.warppool.org/dvb-projekt/dvb-WarpPool
cd dvb-WarpPool
cargo build --release --workspace
./target/release/dvb-warppool-setup # first-run wizard on :8331
Build artifacts land in target/ as usual. If your checkout lives inside a
syncing folder (iCloud Drive, Dropbox, …), point cargo somewhere local instead —
either export CARGO_TARGET_DIR=~/.cache/dvb-WarpPool/target or a personal,
git-ignored .cargo/config.toml with [build] target-dir = …. Adjust the
binary path above accordingly.
Stratum V1 vs V2
The pool can run all three listeners side by side in one process. Only the plain V1 listener is on by default; the other two start once they are configured:
- V1 plain (
:3333) — all existing miner firmwares - V1 TLS (
:3334) — ifstratum_tls_listen+ cert/key are set in config.toml - V2 NOISE-NX (
:34254) — ifsv2_listenis set. Requiressv2_authority_priv_key_hexinsecrets.toml; generate it withdvb-warppool-cli gen-sv2-key. Without the key Sv2 stays disabled with a warning in the log.
The V1 TLS and Sv2 ports are free to choose — :3334 and :34254 are just the
values used throughout this handbook. The connect modal in the UI always shows
the exact host:port the running instance listens on.
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.
Both transports also support the per-worker payout mode (each miner mines to
its own address from the Stratum login) and the public wallet pages at
/users/<address>. See Public 0% Solo Pool.
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.
Reading the hashrate chart
The 24-hour dashboard chart shows two things at once, on two axes:
- The filled area line is the pool hashrate, estimated from accepted shares in 1-minute buckets, read against the left axis (auto-scaled — H/s, KH/s, … up to TH/s).
- The amber dots are best-share peaks — for each time bucket, the highest real share difficulty seen in it — plotted against a separate right axis (share difficulty, scaled K/M/G/T…). They are not on the left hashrate axis.
The line and the dots answer different questions: the line is how much work is flowing, a dot is how close the best share in that slice came to solving a block. The higher a dot, the rarer that share; the all-time best also appears as Best Share on the dashboard. This is the share’s real hash difficulty, independent of the VarDiff target the miner runs at.
The dots come from the per-bucket peak difficulty carried in /api/hashrate —
hover the chart to read the value for that slice. The right axis and the dots
only appear on the full chart once at least one peak has been recorded; mini
charts and a brand-new pool show the line only. After upgrading from an older
version the dots fill in over the first ~24 h, as peak data is recorded going
forward.
Next Steps
- Configuration Reference — all config.toml fields
- Public 0% Solo Pool — per-worker mode (each miner mines
to its own address) + public wallet pages at
/users/<address> - Notifications — set up notifier sinks
- Observability — Prometheus endpoint + Grafana
- Troubleshooting — when something isn’t working
Comparison: dvb-WarpPool vs. ckpool vs. public-pool vs. mkpool
Choosing a self-hosted solo pool comes down to a handful of open-source candidates. This page compares them feature by feature so you can pick the right tool for your setup.
How this table was made — and how to read it fairly:
- Every claim was verified against the source code of the compared
projects, not against their marketing. Full re-verification on
2026-07-21 against ckpool master
308410d(2026-07-20), public-pool master96a9202(2026-06-05, one commit past95565ee, which the Umbrel app pins), and mkpool masterd2e4819(2026-07-19). dvb-WarpPool claims reflect v1.24.0. - All four are actively maintained open-source projects making different trade-offs. ckpool has been the workhorse of solo mining for a decade (the hosted solo.ckpool.org has found hundreds of mainnet blocks); mkpool is a modern multi-coin C++ engine; public-pool is the familiar TypeScript pool with a web UI. Nothing here means “those are bad.”
- The table tracks what ships in the open-source code. Hosted instances (solo.ckpool.org, public-pool.io, mkpool.com) run additional private code you don’t get when self-hosting — mkpool’s README states its database/analytics service, REST API and website are not in the open release, so those rows reflect the engine only. Where a project’s README claims a feature its open code doesn’t implement, we go by the code — and equally, where the code has moved ahead of what this page used to say, the code wins (mkpool’s multi-node failover was listed here as unimplemented and is now wired up end to end).
- Competitor repos move. If a cell is outdated,
open an issue — this
page is only useful while it’s accurate. All rows — ours and the
competitors’ — were last verified 2026-07-21 against the commits named
above. The 2026-07-21 pass changed several rows in the competitors’ favour:
ckpool has gained a Bitcoin Core IPC template path and BIP54 coinbase
handling, and mkpool has gained working multi-node failover, tagged
releases and CI. Those corrections are reflected below. The
address-ownership owner-panel row was re-verified 2026-07-24 against fresh
heads (ckpool
308410d, public-pool96a9202, mkpool4785cd5): none of the three has any signed-message ownership mechanism.
| dvb-WarpPool | ckpool (solo) | public-pool | mkpool | |
|---|---|---|---|---|
| Coins | Bitcoin | Bitcoin | Bitcoin | 9 chains (BTC, BCH, BitcoinII, DigiByte, eCash, ZEC, LTC, DOGE, BCH II; DOGE also merge-mined under LTC) |
| Starting difficulty | hardware-aware, 7 device classes from user agent (unrecognised falls back to the profile default) | 42 by default, operator-configurable (startdiff), same value for every miner | 100,000, same value for every miner | fixed per port tier (default 65,536; SV2 extended channels: seeded from the miner’s nominal hashrate) |
| Sub-1 difficulty | from the first share (NerdMiner class) | no (mindiff 1) | yes — reachable both by the starvation descent (÷6 below 5 recent shares) and by ordinary VarDiff; floor 0.00001 | no (floor 1; configured floors 1024) |
| Difficulty persistence | survives reconnects and pool restarts | no | no | no |
suggest_difficulty / d= | both, clamped to the device class; a repeat or below-current suggest is deduped and treated as a seed, not continuous control | suggest only (no d= password syntax); clamped up to mindiff, and a repeat or already-current suggest is deduped | suggest unclamped, one-shot (latched); d= integer, upward only | both; suggest_difficulty clamped to the tier’s vardiff min/max, d=/diff= clamped per coin — both re-emit set_difficulty (+ a job re-push) on every repeat, and are ignored on fixed-diff tiers |
| VarDiff style | EMA + Poisson significance gate (retarget only when the share-rate clears a 1/√N confidence band) + guarded idle decay, per-class floors | dual EMA (60 s / 300 s window) + fixed 0.15–0.4 share-rate hysteresis band, retarget gated on ≥72 shares or 240 s | 60 s timer + ±2× hysteresis, power-of-2 snaps (downward starvation step ÷6) | dual EMA + fixed hysteresis — a faithful ckpool port, same 0.15/0.40 and 72-share/240 s constants |
| Fixed-difficulty ports | no — VarDiff adapts per device | via separate instances | no | yes (one fixed-diff TCP port per tier) |
| Stratum V2 | 2024 spec, live-verified (Bitaxe / NerdQAxe) | no | not in master (side branch) | yes — Noise NX handshake, EllSwift/BIP324 key exchange, encrypted transport (the source names no spec version) |
| TLS for miners | out of the box + Let’s Encrypt guide | no | no | yes (in-binary) |
| Block/coinbase standards | BIP34 height, SegWit commitment, BIP310 version-rolling (overt ASICBoost), BIP54-forward-compatible coinbase locktime | BIP34, SegWit, version-rolling, and BIP54 (nSequence fffffffe + nLockTime = height−1) | BIP34, SegWit, version-rolling | BIP34, SegWit, version-rolling (mask 1fffe000, off for BCH) |
| Payout model | two modes: a single operator-configured address (default), or opt-in per-worker coinbase — each miner mines to the address in its own stratum login, full reward, one output, and the config rejects a pool fee in that mode | solo mode (btcsolo) pays each miner’s own address from the stratum username; auth is refused if the username is not a valid address. Optional operator donation output, default 0 (shipped example config sets 2%) | pays the address in the stratum username. Optional dev-fee output (1.5%) when DEV_FEE_ADDRESS is set, automatically waived below 50 TH/s | pays the address in the stratum username (V1 and both SV2 channel types), validated twice — locally, then via validateaddress. Optional operator donation output, default 1% but with no default address, so it is inert until configured. Merge-mined DOGE falls back to an operator address if the miner supplies none |
| Coinbase outputs (solo/per-miner path) | 1 (+ SegWit commitment) | 1 (+ commitment), 2 with a donation | 1 (+ commitment), 2 with the dev fee | 1 (+ commitment), 2 with a donation; more on eCash (consensus-mandated outputs) |
| Multi-node failover | yes — sub-second switch, submitblock to all nodes in parallel | yes (btcd failover list) | no (single RPC endpoint) | yes — RPC fallback nodes with automatic switch-back, plus redundant submitblock fan-out and upstream-link failover for the new proxy role |
| Block-change latency | ZMQ from every configured node, first wins | Core IPC notifications when configured, otherwise ZMQ, plus a polling backstop | ZMQ (optional; falls back to a 500 ms poll), which triggers a getmininginfo poll | ZMQ + polling fallback + watchdog |
| Template source | getblocktemplate (RPC) by default + optional Bitcoin Core IPC (Cap’n Proto, experimental, requires Core ≥ 31) — templates straight from kernel notifications, measured ~5–12 ms vs ~89–101 ms for GBT+ZMQ | also both — getblocktemplate by default, with an opt-in Core IPC template path (ipcmining / ipctemplate) that falls back to RPC | getblocktemplate (RPC) | getblocktemplate (RPC) |
| Rate-limit / auto-ban | per-IP on connect/auth/API + escalating ban + manual block list | per-user exponential auth backoff (capped 600 s); no IP or share-rate bans | connection cap, socket timeout, user-agent filter; no per-IP limit or ban list | token-bucket + invalid-share ban, plus a DB-backed address/worker blacklist |
| Web UI / API | PWA, 8 languages, authenticated, live SSE | none (logs + unix socket) | yes — UI in a separate repo; read APIs unauthenticated and CORS fully open (one share-submission endpoint takes an API key) | none in the open release (engine + Postgres writer, plus a local control socket) |
Prometheus /metrics | yes (pool + per-miner + network) | no | no | yes (opt-in build flag) |
| Notifications | 5 sinks + Web Push; friends can subscribe | none | Telegram/Discord (block found, plus a Discord restart notice) | none in the open release |
| Address-ownership owner panel | yes — a miner proves control of the payout address by signing a challenge (BIP-137 for legacy/SegWit, BIP-322 for Taproot), which unlocks a per-address privacy toggle and notification target; no account, no funds moved | no (no per-address owner controls) | no — per-address pages are public, the “settings” route is a no-op stub, and the Telegram/Discord subscribe is unauthenticated (anyone can subscribe to any address) | no (no per-address owner surface in the open release) |
| Device telemetry | Bitaxe / Antminer / Avalon / Whatsminer probes + mDNS | no | no | no |
| Energy & solar tracking | yes | no | no | no |
| Tests | 1201, gating every release in CI | one SHA-256 self-test; no pool-level tests | 63, not run in CI (workflows build/scan the image only) | Catch2 (12 files) + ASan/TSan/UBSan + fuzz harnesses, in CI on a monthly cron and manual dispatch rather than per-push |
| Releases | signed checksums; .deb / .rpm / AppImage / multi-arch Docker from CI, plus a manually built .dmg; reproducible + SBOM (no macOS or Windows CI runner since the forge move: the .dmg is built by hand per release, the Windows .msi is paused with the WiX packaging still maintained) | build from source (autotools); no tags, no in-repo packaging | build from source / Docker image from CI; no tags or releases | build from source; tags v0.2.0 and v.0.3.0 present, plus a release workflow |
| Mainnet track record | not yet — the project is young | many (hosted, since 2014) | yes (hosted) | hosted instance live on mainnet |
| License | AGPLv3 + commercial | GPLv3 | GPLv3 (UI repo: no license file) | GPLv3 |
Note on the IPC template source — no firstness claim
This row previously said that none of the compared pools carried a Bitcoin
Core IPC path. That is no longer true: as of the 2026-07-21 re-check,
ckpool ships an in-tree Cap’n Proto mining IPC implementation
(ipc/mining/mining.capnp, ipc/mining/mining_ipc.cpp) used for both block
notification and template generation, opt-in via the ipcmining and
ipctemplate config keys and falling back to getblocktemplate. That is
architecturally the same choice dvb-WarpPool made. public-pool and mkpool
still go through getblocktemplate only.
It is explicitly not a claim to be “first” or “fastest” with IPC.
Stratum Reference Implementation (SRI) was the first consumer of Core’s IPC
mining interface, and other projects outside this three-way comparison may
well use it too. Core’s IPC interface is itself experimental and has
broken its mining.capnp schema across releases, which is why dvb-WarpPool
keeps GBT+ZMQ as the default and ships IPC strictly opt-in. The latency
figures are our own measurements on our own hardware, not a cross-pool
benchmark.
When the others are the better pick
Honesty cuts both ways:
- ckpool — a minimal, battle-hardened C daemon with a decade of track record, if you’re comfortable with logfiles and a unix socket as your interface. It needs the least trust in young code.
- public-pool — the exact software behind public-pool.io, a familiar UI, and a large user base running the same commit.
- mkpool — if you want multi-coin solo mining (BTC, BCH, LTC, merge-mined DOGE, ZEC and more from one binary), fixed-difficulty ports, RPC-node failover, or a lean modern C++ engine, and you don’t need a built-in operator UI, device telemetry, or packaged/signed releases. It also now ships proxy, passthrough and redirector roles for scaling out across machines, and it’s already live on mainnet.
- dvb-WarpPool — if you run mixed home hardware (NerdMiner next to a Bitaxe next to an Avalon), want TLS / SV2 / notifications / energy tracking and a full operator UI without bolting on extra services, and value tested, signed, reproducible releases. Bitcoin-only by design.
See also
- Performance Benchmarks — measured numbers behind the claims, including the 1,000-miner load test
- Scaling Tiers — what this pool is and isn’t designed for
- Getting Started
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 — replace the path with wherever you cloned the repository:
cd /path/to/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.
Let the whole group get block alerts
The pool can push a block found notification to every friend’s phone the
second it happens. The easiest way is a shared ntfy topic: you configure
[notifier.ntfy] once on the pool, friends just install the ntfy app and
subscribe to the topic name — no accounts needed. If you use it, add one
line to the invite message above:
- Block alerts: install the ntfy app and subscribe to topic
<your-topic-name>
A Telegram group or a Discord channel works too. Full walkthrough: Sharing notifications with friends.
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. There is a global connection cap (profile-driven) and a
set of opt-in guards — a per-IP concurrent-connection cap
(
[stratum] max_connections_per_ip,0= off by default) plus connect-rate auto-banning ([ratelimit] autoban_*, enabled by default, loopback and private LAN exempt). That blunts noisy clients; it is not upstream DDoS mitigation. - Authentication beyond bech32 address format by default (anyone with your
hostname can point a miner at you and submit shares — to their own address).
For an invite-only pool you can pin the accepted payout addresses with
[mining] address_allowlist; V1mining.authorizefor any other address is then rejected. - 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) — adjust the path to your clone
cd /path/to/dvb-WarpPool && ./scripts/setup-tls-public.sh
# Daemon restart (rarely needed — the daemon picks up renewed certs on its own)
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!
Solo + Friends — Ausführliche Anleitung (Deutsch)
Diese Anleitung verwandelt deinen lokalen dvb-WarpPool in einen Pool, den deine Freunde von zu Hause aus mit ihren Minern über das Internet erreichen können — verschlüsselt über TLS, mit echtem Let’s Encrypt-Zertifikat (kein “Insecure Mode” am Miner nötig).
Zeitbedarf: ca. 15-20 Minuten Vorausgesetzt: WarpPool läuft schon lokal auf deinem Mac, Fritz!Box ist dein Router
Inhalt
- Was wir hier eigentlich machen
- Phase 1 — DuckDNS-Account anlegen
- Phase 2 — Fritz!Box: DDNS einrichten
- Phase 3 — Fritz!Box: Port 3334 freigeben
- Phase 4 — Setup-Script ausführen
- Phase 5 — Daemon neu starten
- Phase 6 — Verifikation
- Phase 7 — Eigenen Miner umstellen
- Phase 8 — Freunde einladen
- Sleep-Schutz auf macOS
- Wartung
- Wenn was schiefgeht
- Security-Hinweise zu dieser Ausbaustufe
- Zusammenfassung der Befehle
Was wir hier eigentlich machen
Das Problem: Bitaxe-Firmware mit “Bundled CA”-Modus akzeptiert nur Zertifikate von echten Certificate Authorities (Mozilla Root Bundle). Dein aktuelles Self-signed Cert wird abgelehnt. → Wir brauchen ein echtes Cert.
Echte Certs gibt’s gratis bei Let’s Encrypt — aber nur für einen DNS-Namen
(nicht für eine bloße IP-Adresse). Du brauchst also einen “Namen für deinen
Mac im Internet”. Das macht DuckDNS: gratis Subdomain <wunschname>.duckdns.org,
die immer auf deine aktuelle Heim-IP zeigt.
Was du am Ende hast:
<sub>.duckdns.orgzeigt auf deine Fritz!Box-IP (auch wenn sich die ändert)- Fritz!Box leitet Port 3334 auf deinen Mac weiter
- Mac antwortet mit echtem Cert für
<sub>.duckdns.org - Bitaxe vertraut dem Cert (weil Let’s Encrypt im Bundle ist)
- → Externe Miner können sich verschlüsselt verbinden
Phase 1 — DuckDNS-Account anlegen
Zeit: 2 Min.
- Browser auf https://www.duckdns.org/ öffnen.
- Oben rechts mit GitHub, Twitter, Reddit oder Google einloggen (am einfachsten GitHub falls du dort schon einen Account hast).
- Du landest auf der Übersichtsseite. Oben siehst du:
- Token: eine 32-stellige Hex-Zahl wie
a1b2c3d4-e5f6-... - Darunter ein leeres Feld “sub domain” mit “add domain”-Button daneben.
- Token: eine 32-stellige Hex-Zahl wie
- Wunschname eintippen (z.B.
warppool-oliveroderoliver-pool— was dir gefällt, muss noch frei sein) → “add domain” klicken. - Die Subdomain erscheint in der Liste. Notiere dir:
- Subdomain (nur das
warppool-oliver-Teil, ohne.duckdns.org) - Token (oben rechts, 32 Hex-Zeichen)
- Subdomain (nur das
Beides brauchst du in Phase 2 und Phase 4.
Tipp: Den Token nicht in Slack/Discord posten. Wer ihn hat, kann deine Subdomain auf eine fremde IP umlenken.
Phase 2 — Fritz!Box: DDNS einrichten
Zeit: 3 Min.
DDNS = Dynamic DNS. Deine Fritz!Box ruft alle paar Minuten DuckDNS auf und
sagt “die IP für warppool-oliver ist jetzt X”. Damit zeigt deine
Subdomain immer auf dein Zuhause, auch wenn dein Provider die IP wechselt.
-
Browser auf http://fritz.box/ oder http://192.168.178.1/.
-
Einloggen mit deinem Fritz!Box-Passwort.
-
Linkes Menü: Internet → Freigaben.
-
Reiter DynDNS wählen.
-
Häkchen bei “DynDNS benutzen” setzen.
-
DynDNS-Anbieter: “Benutzerdefiniert” wählen.
-
Update-URL: exakt diese Zeile einfügen (Subdomain + Token durch deine ersetzen):
https://www.duckdns.org/update?domains=<DEINE_SUBDOMAIN>&token=<DEIN_TOKEN>&ip=<ipaddr>Beispiel (mit Beispiel-Werten):
https://www.duckdns.org/update?domains=warppool-oliver&token=a1b2c3d4-e5f6-7890-abcd-ef1234567890&ip=<ipaddr>Wichtig: Das
<ipaddr>am Ende ist ein Platzhalter — den lässt du exakt so stehen. Die Fritz!Box ersetzt das beim Senden mit deiner aktuellen IP. -
Domainname:
<DEINE_SUBDOMAIN>.duckdns.org(z.B.warppool-oliver.duckdns.org). -
Benutzername: irgendwas Beliebiges (z.B.
none) — DuckDNS prüft nur den Token. -
Kennwort: auch irgendwas (z.B.
none). -
“Übernehmen” klicken.
Test: Im Terminal auf dem Mac:
dig +short warppool-oliver.duckdns.org @8.8.8.8
Sollte deine öffentliche IP zurückgeben (dieselbe wie unter https://www.whatismyip.com/ angezeigt). Falls leer: 30 Sekunden warten, nochmal versuchen — Fritz!Box muss erst einmal updaten.
Phase 3 — Fritz!Box: Port 3334 freigeben
Zeit: 3 Min.
Externe Miner verbinden sich zu warppool-oliver.duckdns.org:3334 →
landen bei deiner Fritz!Box → die muss wissen wohin damit (= deinem Mac).
- Fritz!Box → Internet → Freigaben → Reiter Portfreigaben.
- Klicke “Gerät für Freigaben hinzufügen” (oder bei einem bereits vorhandenen Gerät auf “Bearbeiten”).
- Wähle dein Mac-Gerät aus (sollte als hostname-irgendwas oder
MacBook-Proetc. erscheinen — IP192.168.178.10). - Klicke “Neue Freigabe” → “Portfreigabe”.
- Anwendung: “Andere Anwendung”.
- Bezeichnung:
WarpPool TLS Stratum(für deine Übersicht). - Protokoll: TCP.
- Port an Gerät:
3334 - Port extern gewünscht:
3334 - Häkchen bei “Freigabe aktivieren”.
- “OK” → dann “Übernehmen”.
Test: Auf einem externen Gerät (z.B. dein Handy mit ausgeschaltetem WLAN, also über Mobilfunk):
nc -zv warppool-oliver.duckdns.org 3334
Sollte Connection to warppool-oliver.duckdns.org port 3334 [tcp/*] succeeded!
zeigen.
Falls das nicht geht von extern aber vom Mac selbst funktioniert: NAT-Loopback-Problem der Fritz!Box (kennt manche Modelle). Externe Tests aber von extern machen.
Phase 4 — Setup-Script ausführen
Zeit: 2 Min.
Jetzt holen wir das Let’s Encrypt-Cert.
-
Terminal öffnen.
-
In den WarpPool-Ordner wechseln — den Pfad durch dein eigenes Clone- Verzeichnis ersetzen:
cd /pfad/zu/dvb-WarpPool -
Script starten:
./scripts/setup-tls-public.sh -
Das Script fragt dich:
? DuckDNS Subdomain (ohne .duckdns.org):→
warppool-oliver(oder was du in Phase 1 gewählt hast)? DuckDNS Token (32-Hex aus deinem Account-Settings):→ den Token aus Phase 1 reinpasten
-
Das Script läuft jetzt durch:
- Installiert
acme.sh(Let’s Encrypt CLI) wenn nicht da → ~30s - Holt das Cert via DNS-01-Challenge:
- Setzt einen TXT-Record bei DuckDNS
- Wartet bis DNS propagiert ist (~30-60s)
- Let’s Encrypt prüft den TXT-Record → bestätigt dass du die Domain kontrollierst
- Cert wird ausgestellt + lokal gespeichert
- Installiert Cert + Key nach
~/Library/Application Support/dvb-WarpPool/tls/{cert,key}.pem - Alte self-signed Files werden als
.bak-<unix-ts>gesichert - Registriert sich selbst im Cron für Auto-Renewal nach 60 Tagen
- Installiert
-
Am Ende siehst du:
✓ TLS-Setup für warppool-oliver.duckdns.org abgeschlossen Nächste Schritte: 1) WarpPool-Daemon neustarten ... 2) Auf der Bitaxe (Pool Configuration): ... 3) Fritz!Box prüfen: ... 4) Test (von extern oder im LAN): ...
Phase 5 — Daemon neu starten
Zeit: 30 Sek.
Der Daemon lädt das Cert nur beim Start. Also einmal neustarten:
pkill -f dvb-warppool-daemon
sleep 2
open /Applications/dvb-WarpPool.app
Nach 3-5 Sekunden ist der Daemon wieder da. Prüfen:
tail -20 ~/Library/Application\ Support/dvb-WarpPool/daemon.log | grep -i "tls"
Sollte zeigen:
spawning TLS stratum listener bind=0.0.0.0:3334 cert=/Users/Oliver/Library/Application Support/dvb-WarpPool/tls/cert.pem
stratum-v1 TLS server listening bind=0.0.0.0:3334 max_conn=64
Phase 6 — Verifikation
Zeit: 1 Min.
Wir checken dass das Cert von extern als gültig erscheint.
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"
Gut (was du sehen willst):
subject=CN=warppool-oliver.duckdns.org
issuer=C=US, O=Let's Encrypt, CN=R10
Verify return code: 0 (ok)
Schlecht (irgendwas stimmt nicht):
subject=CN=dvb-WarpPool ← noch das self-signed Cert
Verify return code: 18 (self-signed certificate)
Falls du das schlechte Resultat siehst:
- Hast du den Daemon wirklich neugestartet?
pgrep -f dvb-warppool-daemonsollte einen anderen PID zeigen als vor dem Restart. - Zeigt die config.toml auf das neue Cert?
grep tls_cert_path ~/Library/Application\ Support/dvb-WarpPool/config.toml
Bei “Verify return code: 0” — fertig, Cert ist gültig und Bitaxe wird’s akzeptieren.
Phase 7 — Eigenen Miner umstellen
Zeit: 2 Min. pro Miner.
Beispiel BitForge Nano (Web-UI auf http://192.168.178.44):
-
Im Browser zu
http://192.168.178.44/(oder wie auch immer dein Bitaxe im LAN heißt). -
Pool Configuration → Main Pool Reiter.
-
Felder so setzen:
Feld Wert Stratum Host warppool-oliver.duckdns.orgStratum Port 3334Stratum User (bleibt: bc1q...DEINE_ADRESSE.BitForgeNano)Stratum Password (bleibt was es war, z.B. x)TLS Enabled (Bundled CA) ← jetzt korrekt -
Save klicken.
-
Bitaxe meldet “must restart this device after saving” → Restart klicken.
-
Bitaxe rebootet. Nach ~20 Sek. ist er zurück.
-
Im Bitaxe-UI sollte unter “Status” wieder eine aktive Pool-Verbindung stehen + Shares ticken.
-
Im WarpPool-Admin (
http://localhost:18334/): Worker-Tabelle sollte den Bitaxe weiter zeigen — mit 🔒-Schloss-Icon neben dem Namen für die TLS-Verbindung. Das “Aktive Miner”-Tile zeigt eine “N TLS”-Subzeile mit der Zahl deiner TLS-Verbindungen.
→ Erfolgreich auf TLS umgestellt.
Wiederholung für alle anderen eigenen Miner (NerdOctaxe, NerdQaxe++, Avalon Q etc.). Pro Miner ~2 Min.
Phase 8 — Freunde einladen
Schick deinen Freunden:
Hey, du kannst auf meinen WarpPool mining:
- Pool:
warppool-oliver.duckdns.org- Port:
3334- TLS: ja, “Enabled (Bundled CA)” / “Strict TLS”
- Username:
<DEINE_EIGENE_bech32-Adresse>.<beliebiger Worker-Name>Du bekommst 100% des Rewards wenn dein Miner den Block findet (Solo-Pool, kein Sharing). Wenn der Pool offline ist, geh auf deinen Fallback.
Wichtig: Jeder Freund nutzt seine eigene Wallet-Adresse. WarpPool ist Solo — der Reward geht direkt auf die Adresse die der Miner als User angibt.
Block-Alarm für die ganze Gruppe
Der Pool kann jedem Freund eine Block-gefunden-Benachrichtigung aufs
Handy schicken, in der Sekunde in der es passiert. Der einfachste Weg ist
ein gemeinsames ntfy-Topic: du konfigurierst [notifier.ntfy] einmal am
Pool, deine Freunde installieren nur die ntfy-App und abonnieren den
Topic-Namen — ganz ohne Accounts. Wenn du das nutzt, ergänze die
Einladung oben um eine Zeile:
- Block-Alarm: installiere die ntfy-App und abonniere das Topic
<dein-topic-name>
Eine Telegram-Gruppe oder ein Discord-Channel funktionieren genauso. Die komplette Anleitung (englisch): Sharing notifications with friends.
Sleep-Schutz auf macOS
Sobald deine Freunde von außen connecten, muss der Pool 24/7 erreichbar
bleiben. Per Default suspendiert macOS das System nach 3 Stunden Idle-Zeit
(pmset sleep 180). Während des Sleep:
- Bitcoin Core ist eingefroren —
getblocktemplateundsubmitblocktimen aus - Stratum-TCP-Verbindungen brechen ab (Bitaxes loggen
Connection reset by peer) - Der Hashrate-Chart zeigt nachts mehrere Drops auf 0 mit kurzen Erholungen wenn der Mac für Power Nap oder Notifications kurz aufwacht
Das wird automatisch verhindert. Seit v1.0.6 wrapped der Mac-Launcher den
Daemon-Spawn mit caffeinate -i -w <pid>:
-i= verhindert System-Idle-Sleep (CPU + Netzwerk + Bitcoin Core bleiben wach)-w PID= caffeinate beendet sich automatisch wenn der Daemon stirbt → keine Zombie-Prozesse, kein manueller Cleanup- Display-Sleep funktioniert weiter — du kannst den Bildschirm dunkel werden lassen, Screensaver läuft, alles normal
Verifikation dass es aktiv ist:
ps -A -o pid,ppid,command | grep "caffeinate -i -w $(pgrep -f dvb-warppool-daemon | head -1)"
Solltest eine Zeile sehen: caffeinate -i -w <daemon-pid>.
Die Admin-Profile-Seite (/admin/profile) zeigt eine grüne Info-Card
“✓ macOS Sleep-Modus deaktiviert” wenn der Schutz aktiv ist. Falls die
Card fehlt: Launcher konnte caffeinate nicht spawnen (sehr unwahrscheinlich —
es ist ein System-Binary das auf jedem macOS vorinstalliert ist).
Was NICHT abgedeckt ist
- Expliziter Sleep (Apple-Menü → Ruhezustand, oder MacBook-Deckel schließen). Der User will Sleep, also schläft der Pool auch. Deckel öffnen / Maus bewegen und der Pool ist innerhalb Sekunden zurück (Daemon überlebt kurze Suspensions; bei längeren reconnecten die Bitaxes).
- Hard-Shutdown / Stromausfall. Bei Sorgen: USV nutzen.
- Force-Quit des Launchers. Wenn du den Launcher direkt killst
(
Activity Monitor → Force Quit), überlebt der Daemon — aber caffeinate’s Parent ist weg, also könnte caffeinate exiten. Sauberer Restart viapkill -f dvb-warppool-daemonoder die.appneu öffnen.
Stromverbrauch
caffeinate -i ist nur ein Sleep-Prevention-Flag — es pinnt nicht die
CPU oder hält die Disk drehend. Dein Mac mini / MacBook idled mit dem
gleichen Watt-Wert wie sonst auch wenn der Bildschirm aus ist. Der
eigentliche Pool-Workload (Stratum-I/O + gelegentliche Bitcoin-Core-RPC)
liegt auf Apple Silicon im einstelligen Watt-Bereich. Keine merklichen
Auswirkungen auf die Stromrechnung eines Mac mini.
Sleep-Schutz deaktivieren
Wenn der Mac doch schlafen darf (z.B. dir ist Pool-Uptime nachts egal): caffeinate manuell killen:
pkill caffeinate
Der Daemon läuft weiter bis macOS ihn suspendiert.
Es gibt aktuell keinen Config-Flag zum Deaktivieren des Sleep-Schutzes — es ist das richtige Verhalten für fast jeden Pool-Operator, und das Deaktivieren macht den Pool unzuverlässig. Issue aufmachen falls du einen echten Use-Case für Opt-Out hast.
Wartung
Cert-Renewal
acme.sh macht das automatisch alle 60 Tage (Let’s Encrypt-Certs gelten 90
Tage). Du musst nichts tun außer den Daemon einmal neu starten nachdem
das Renewal lief, damit er das neue Cert lädt.
Pragmatischer Trick: Cron-Job der das nach jedem Renewal-Versuch macht:
crontab -e
Folgende Zeile dazu:
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
Läuft jeden Tag um 4 Uhr morgens: prüft Renewal-Status, restartet Daemon falls was passiert ist. Wenn nichts zu renewen war, macht der Restart auch keinen Schaden (Daemon ist nach 5s wieder da, Bitaxes reconnecten automatisch).
Manuell Renewal triggern
Falls du testen willst:
~/.acme.sh/acme.sh --renew --domain warppool-oliver.duckdns.org --force
Das --force ignoriert das 60-Tage-Limit.
IP-Wechsel
Solange du die Fritz!Box hast, läuft alles weiter — sie updated DuckDNS ständig. Nur wenn du den Router wechselst (neuer Anbieter, neue Hardware) musst du die DDNS-Config im neuen Router nochmal einrichten (Phase 2).
Stop / Start des Pools
Nichts Besonderes — wie bisher die .app schließen oder pkill -f dvb-warppool-daemon. Bitaxes der Freunde gehen automatisch auf Fallback,
kommen zurück sobald dein Pool wieder läuft.
Wenn was schiefgeht
Bitaxe meldet “TLS connection failed”
-
Fritz!Box Port-Forward weg?
nc -zv warppool-oliver.duckdns.org 3334von extern → muss “succeeded” sagen. -
DuckDNS resolved nicht oder auf falsche IP?
dig +short warppool-oliver.duckdns.org @8.8.8.8→ muss deine aktuelle Public-IP zeigen (vergleiche mitcurl ifconfig.me). -
Cert abgelaufen? (sollte nicht passieren wegen Auto-Renewal, aber zur Sicherheit):
echo | openssl s_client -connect warppool-oliver.duckdns.org:3334 \ -servername warppool-oliver.duckdns.org 2>&1 \ | openssl x509 -noout -datesnotAfter=...sollte mindestens 30 Tage in der Zukunft sein. -
Bitaxe-Firmware-Cache: manche Bitaxe-Firmware-Versionen cachen ein einmal-gefailtes Cert. Bitaxe physisch vom Strom trennen (10 Sek. warten, wieder anstecken), nicht nur Reset-Button.
Script hat irgendwo abgebrochen
Mehrmals ausführen ist OK — das Script ist idempotent. Wenn z.B. acme.sh
schon installiert ist, wird es übersprungen.
Falls du komplett neu anfangen willst:
~/.acme.sh/acme.sh --remove --domain warppool-oliver.duckdns.org
rm -rf ~/.acme.sh/warppool-oliver.duckdns.org/
Dann Script nochmal.
Cert lädt nach Restart nicht neu
Daemon zeigt im Log noch das alte Cert?
lsof -p $(pgrep -f dvb-warppool-daemon) | grep cert.pem
Sollte den Pfad zum neuen Cert zeigen. Wenn nicht: nochmal pkill -KILL -f dvb-warppool-daemon (statt normalem pkill) + die .app neu öffnen.
“Rate Limit Exceeded” von Let’s Encrypt
Du darfst nur 5 Certs pro Woche pro Domain. Wenn du beim Testen mehrfach ausgestellt hast: warten oder die Staging-Variante nutzen:
~/.acme.sh/acme.sh --issue --dns dns_duckdns --domain warppool-oliver.duckdns.org \
--server https://acme-staging-v02.api.letsencrypt.org/directory
Staging-Certs sind nicht in der Mozilla-CA-Liste — funktionieren nicht für Bitaxe, aber zum Testen ob die Infrastruktur läuft.
Komplett rückgängig (zurück auf self-signed)
Falls du doch nur LAN-Betrieb willst:
~/.acme.sh/acme.sh --remove --domain warppool-oliver.duckdns.org
cd ~/Library/Application\ Support/dvb-WarpPool/tls/
# Backup-Files vom Setup-Script restore:
ls -la *.bak-*
# Den jüngsten cert.pem.bak-XXX und key.pem.bak-XXX zurückkopieren:
cp cert.pem.bak-<JÜNGSTER> cert.pem
cp key.pem.bak-<JÜNGSTER> key.pem
pkill -f dvb-warppool-daemon ; sleep 2 ; open /Applications/dvb-WarpPool.app
Plus: Bitaxe wieder auf Port 3333 ohne TLS stellen (oder TLS “Insecure”-Mode).
Security-Hinweise zu dieser Ausbaustufe
Dieses Setup gibt dir:
- Verschlüsselten Stratum-Traffic (TLS 1.3 mit AES-256-GCM)
- Verifizierte Server-Identität via Let’s Encrypt → kein Man-in-the-Middle kann sich als dein Pool ausgeben
- Freunde mit Tippfehler im Hostname bekommen eine Zertifikats-Warnung → fail-safe statt stillem Minen in einen fremden Pool
Es gibt dir nicht:
- DDoS-Schutz (64-Verbindungs-Cap, kein Per-IP-Limit)
- Authentifizierung über das bech32-Adressformat hinaus (jeder mit deinem Hostname kann einen Miner auf dich richten und Shares einreichen — auf die eigene Adresse)
- IP-Privatsphäre der Worker (sichtbar im Admin-Panel + in den Logs)
Für 5-10 Freunde ist das völlig ok. Leakt dein DuckDNS-Hostname öffentlich und Fremde fangen an, auf deinem Pool zu minen, hilft nur ein Hostname-Wechsel. Für echte Abuse-Resistenz brauchst du Stufe 3 — siehe scaling.md.
Zusammenfassung der Befehle
Für später als Spickzettel:
# Setup (1x) — Pfad an dein Clone anpassen
cd /pfad/zu/dvb-WarpPool && ./scripts/setup-tls-public.sh
# Daemon-Restart (selten nötig — erneuerte Certs zieht der Daemon selbst nach)
pkill -f dvb-warppool-daemon ; sleep 2 ; open /Applications/dvb-WarpPool.app
# Manuelles Renewal triggern
~/.acme.sh/acme.sh --renew --domain warppool-oliver.duckdns.org --force
# Cert-Status prüfen
echo | openssl s_client -connect warppool-oliver.duckdns.org:3334 \
-servername warppool-oliver.duckdns.org 2>&1 \
| openssl x509 -noout -dates -subject -issuer
# DNS-Resolution prüfen
dig +short warppool-oliver.duckdns.org @8.8.8.8
# Port-Reachability von extern (vom Handy mit Mobilfunk)
nc -zv warppool-oliver.duckdns.org 3334
Viel Erfolg!
Public 0% Solo Pool — Per-Worker Setup & Hardening
This guide turns dvb-WarpPool into a public, non-custodial, 0%-fee solo pool: any miner who connects mines to their own BTC address (taken from the Stratum login), and the full block reward goes straight to the finder on-chain. The pool never holds funds — there is nothing to withdraw, nothing to trust. It is the natural next tier above the friends-only pool in Scaling Tiers.
How it differs from single mode: in the default single mode the whole
reward pays one operator address (Solo + Friends). In
per_worker mode every miner supplies its own address as the Stratum
username, and the coinbase is built per-connection to pay exactly that address
— one output, full subsidy, no fee. See the design notes in
docs/design/per-worker-coinbase.md.
Honesty: WarpPool has not yet found a mainnet block. Advertise the pool as what it is — a fresh, open, non-custodial solo pool — never as “battle-tested”. The consensus path (per-worker coinbase → real Bitcoin Core accepts the block → reward pays the finder) is verified end-to-end on regtest, but the first mainnet block is still “not yet”.
The project runs one such pool itself —
pool.warppool.org, per-worker, 0% fee, non-custodial, currently around a dozen miners. Where this guide says “the reference deployment”, that is the box it means. It has not found a mainnet block either.
Contents
- Turn on per-worker mode
- What miners enter
- Public hardening profile
- TLS
- Behind a reverse proxy
- Deployment checklist
1. Turn on per-worker mode
In config.toml:
[mining]
payout_mode = "per_worker"
# payout_address is IGNORED in per_worker mode (each miner brings its own).
# pool_fee_percent / operator_donation_percent MUST be 0 — the daemon
# refuses to start otherwise (full reward goes to the finder).
That single flag is the whole feature switch. The daemon fails closed on
startup if a fee or donation is configured together with per_worker, so you
cannot accidentally run a public pool that skims the reward.
Setup wizard shortcut. If you install through the setup wizard (
dvb-warppool-setup), the Mining card offers a Pool type choice — Private solo pool vs Public 0% pool. Picking Public 0% pool writespayout_mode = "per_worker"for you, hides the pool-wide payout address (it is unused here), and forces the fee to 0. No hand-editing required.
An invalid or wrong-network login address is rejected at
mining.authorize (V1) / channel-open (Sv2) — there is no fallback to an
operator address, so a typo never silently pays the wrong person.
2. What miners enter
Miners set their worker/username to their own address, optionally with a rig suffix after a dot:
Username: bc1qyour-own-address.rig1
Password: x
- Everything before the first
.is the payout address; the rest is a free rig label used only for stats. - Supported address types: P2PKH, P2SH, P2WPKH, P2WSH, P2TR (Taproot). Any valid mainnet address works.
Public wallet pages & the optional owner panel
Every miner gets a login-free per-wallet page at /users/<address> — the
same “look up my address” flow a public solo pool is expected to have. It shows
that address’s aggregate hashrate (1 m / 5 m / 1 h / 1 d / 7 d), a per-rig
breakdown, lifetime accepted shares, best-ever share difficulty, and the
first-seen time. No account, no operator involvement — anyone with the address
can open it, and a miner can bookmark their own.
The same numbers are served as ckpool-compatible JSON at
GET /api/users/<address> — the exact field names (hashrate1m,
hashrate1hr, bestshare, worker[], …) that firmware displays and stats
tools like ckstats already parse. Both routes are plain HTTP under the status
port, so they sit behind the same reverse proxy as the dashboard
(Reverse Proxy).
By default a wallet page is public. A miner who wants more control can prove ownership of the address and unlock an opt-in owner panel — entirely per-miner, nothing the operator has to configure:
- The page hands out a short one-time challenge message.
- The miner signs it in their own wallet’s Sign Message box (BIP-137 signed-message — the standard “verify address” feature in Electrum, Sparrow, Bitcoin Core, hardware wallets). No private key leaves the wallet, no funds move, no account is created — the pool only checks the signature.
- A valid signature sets a short-lived owner cookie for that address and
reveals two controls:
- Privacy — hide the address from the public wallet list and blank its
/users/<address>data for everyone but the verified owner. - Notifications — point a personal ntfy topic or webhook at this address’s milestones: block-found (this address found a block), personal-best (a new best-ever share across all its rigs), top-100 (first entry into the pool’s all-time top-100 best shares) and rig-offline (one of its rigs dropped). Each event type has its own cooldown, and a found block sends a single headline instead of overlapping alerts.
- Privacy — hide the address from the public wallet list and blank its
Ownership signing covers single-key P2PKH / P2WPKH / P2SH-P2WPKH addresses; Taproot (P2TR) can mine but cannot use the owner panel, because the legacy signed-message format has no Taproot variant. Full walk-through in Notifications → Per-wallet miner notifications; the owner panel needs no operator config — see the Configuration Reference note.
Operator-wide directory & leaderboard privacy
The per-miner owner panel above is opt-in and only the miner controls it. As the
operator you can additionally set the default exposure of the whole UI, to
match the convention of established solo pools (solo.ckpool.org publishes no
browsable directory; AtlasPool masks leaderboard addresses). Both default to the
open behaviour, so this is opt-in:
[privacy]
# Hide the browsable Workers/wallet directory from anonymous visitors. Each
# /users/<address> page still works by direct link; the logged-in operator still
# sees the full directory (so keep admin auth enabled).
wallet_directory_public = false
# Mask the Bitcoin address in the public best-shares leaderboard (bc1qsx…dvng),
# server-side. The logged-in operator still sees full addresses.
leaderboard_addresses_public = false
This is the recommended posture for a public mainnet pool: miners reach their own page by link, but the pool doesn’t hand out an enumerable list of everyone’s address and rig setup. It does not touch payouts or block construction. See the Configuration Reference.
Accepting donations (optional)
Because a per-worker pool takes 0% of every reward, the only way to fund
hosting is a voluntary donation. The dashboard has built-in support for this,
and it appears only in per_worker mode — a private solo pool never shows
it.
Set the addresses in the admin UI under Admin → Donations (the nav entry is hidden unless the pool runs in per-worker mode):
- On-chain — any valid Bitcoin address. It is validated on save and shown
as a
bitcoin:QR the visitor’s wallet can open directly. - Lightning — an LN address / LNURL / BOLT12 string (stored as-is).
Either may be left blank to hide that option. When at least one is set, a
Support this pool card with copy buttons and QR codes appears at the bottom
of the public dashboard. The addresses are plain operator metadata — they never
touch coinbase construction, so setting one can’t affect the fail-closed
payout_mode invariant (a donation here is a visitor sending sats to a
displayed address, not a share of the block reward).
Under the hood the addresses are stored as pool settings (not config.toml),
so no restart is needed; POST /api/admin/donations is admin-authenticated and
GET /api/donation-qr/{onchain,lightning} renders the QR SVGs.
3. Public hardening profile
A pool exposed to the internet needs the abuse controls a friends-only pool can skip. Every key below already exists; none is per-worker-specific — they apply to any public deployment. Full detail in the Configuration Reference.
[stratum]
# Bound how many simultaneous V1 connections one source IP may hold. On a
# public pool this stops a single host from hogging slots. Leave 0 only if you
# expect many miners behind one NAT.
max_connections_per_ip = 50
[ratelimit]
enabled = true
connects_per_sec = 5
connect_burst = 20
auths_per_sec = 1.0
auth_burst = 10
# Auto-ban IPs that keep tripping the connect limit (escalating). Loopback and
# private LAN are never banned.
autoban_enabled = true
autoban_strikes = 3
autoban_base_secs = 60
autoban_max_secs = 86400
autoban_window_secs = 600
# Ban a worker that floods invalid shares (share-flood defence). Off by
# default; turn on for a public pool.
reject_ban_enabled = true
reject_ban_min_samples = 100
reject_ban_ratio = 0.9
# Optional subnet access control. `ban_cidrs` denies ranges outright;
# `allow_only = true` flips to a strict whitelist (only allow_cidrs connect).
# ban_cidrs = ["203.0.113.0/24"]
# allow_only = false
# allow_cidrs = ["192.168.0.0/16"]
Optional invite-only variant — combine per-worker payouts with an address allowlist so only known addresses may mine:
[mining]
payout_mode = "per_worker"
address_allowlist = ["bc1qalice...", "bc1qbob..."]
Also switch on automated snapshots before you go public — a public pool’s stats and best-share history are worth keeping:
[backup]
enabled = true
interval_secs = 86400 # daily; the daemon clamps to >= 60
dir = "backups" # relative paths resolve against the working directory
keep = 7 # 0 = keep everything
These are JSON ledger snapshots. They contain no secrets and no raw shares, so they are safe to copy off-box to ordinary storage.
Sizing profile
A public pool normally runs the gross profile (its defaults are tuned for
roughly 21-100 workers): connection cap, VarDiff bounds, memory budget and
retention in one bundle. The range in the name is guidance, not a limit —
nothing rejects the 101st miner, and profiles hot-switch at runtime without a
restart (Admin → Profile).
Resist the urge to move to enterprise the moment you pass 100 miners: its
VarDiff defaults are calibrated for PH/s-class farms and will start far too high
for Bitaxe/NerdMiner-class hardware, which is exactly what an open 0% pool
attracts. Compare the profiles side by side in the admin UI first. See
Scaling Tiers.
The node behind the pool
The pool can only mine on the tip it knows about, so the node’s own network position is an operational concern, not a detail:
- Accept inbound connections. Forward TCP 8333 and keep
listen=1on. A node nobody can dial learns about each new block only through its own outbound peers, and every extra millisecond of tip latency is time your miners spend on a job that can no longer win. The health check warns whenpeers.inbound = 0on mainnet — take that warning seriously here (Setup Health-Checks). - Pruned is fine. Solo mining needs no
txindex; the reference deployment runs a pruned Bitcoin Core 31.1 with 8333 open at roughly 30 peers. - Consider a backup node.
[[node.backup]]entries make a primary outage a non-event, and solved blocks are submitted to every configured node in parallel — see Resilience.
4. TLS
Firmware that only trusts public CAs needs a real certificate. The Solo + Friends guide walks through DuckDNS + Let’s Encrypt end-to-end; the relevant keys are:
[stratum]
stratum_tls_listen = ":3334"
tls_cert_path = "/etc/dvb-warppool/tls/cert.pem"
tls_key_path = "/etc/dvb-warppool/tls/key.pem"
Leave the two paths unset to use the daemon’s auto-generated self-signed certificate (fine for NerdMiner/Bitaxe-class firmware that lets you pin it).
5. Behind a reverse proxy
When the status UI is served over HTTPS through nginx/Caddy:
[server]
status_public_url = "https://pool.example.com"
trust_proxy_headers = true # so per-IP limits see the real client IP
cookie_secure = true # UI is HTTPS
# status_tls_listen = ":18443" # or terminate TLS at the proxy instead
See Reverse Proxy for ready-to-paste nginx/Caddy configs
(TLS termination, X-Forwarded-For, and the /api/events SSE passthrough).
With
trust_proxy_headers = truethe daemon trustsX-Forwarded-For. Only enable it when a proxy you control actually sets that header — otherwise a client could spoof its IP past the per-IP limits.
6. Deployment checklist
-
payout_mode = "per_worker", no pool fee / donation (daemon starts). - Bitcoin Core reachable and inbound-reachable (TCP 8333 forwarded,
listen=1) so tip changes arrive fast; health check shows nopeers.inbound = 0warning. - Optionally
[node] template_source = "ipc"for lower template latency (needs Core ≥ 31 built with IPC; Core’s IPC interface is itself experimental — GBT stays the default and is the way back). -
[backup] enabled = truewith akeepcount you’re happy with. -
[ratelimit] enabled = truewithautoban_enabledandreject_ban_enabledon. -
max_connections_per_ipset (unless miners sit behind one NAT). - TLS in front of Stratum (own cert for public-CA-only firmware).
- Reverse proxy:
trust_proxy_headers+cookie_secureset correctly. - Test a miner with
your-address.rig1; confirm it appears at/users/<address>and shares are accepted. - If you publish a landing page, keep the honesty guardrails: no “battle-tested”; mainnet block “not yet”.
- Optional
[legal]footer (terms/privacy links) for a public/commercial deployment.
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. Note that “public” alone does not put a deployment out of OSS scope — a non-custodial per-worker pool is fully covered (see Public 0% Solo Pool); what needs the commercial stack is holding funds, sharing rewards, and running a service with support obligations.
Tier Matrix
| Tier | Use case | OSS sufficient? | Profile |
|---|---|---|---|
| Solo | One operator, own miners on LAN | Yes | klein (0-5 miners) |
| Solo + Friends | 5-20 miners, trust-based, LAN or Internet | Yes | mittel (6-20) |
| Community / public per-worker pool | 20-100 miners, vetted or openly non-custodial | Yes | gross (21-100) |
| Custodial / reward-sharing service | Publicly advertised pool that holds funds or splits rewards | No — requires commercial stack | enterprise (101+) |
The profile is a bundle of defaults, not a hard limit. A profile presets the connection cap, VarDiff bounds, memory budget and retention — nothing enforces the worker range in its name, and the software has been load-tested with 1,000 simulated miners against a single instance (see Benchmarks). Profiles hot-switch at runtime without a daemon restart (Admin → Profile,
POST /api/admin/profile); the connection cap is resized live.Do not jump to
enterprisejust because you crossed 100 miners. Its VarDiff defaults are calibrated for PH/s-class farms and start far too high for a pool full of Bitaxe/NerdMiner-class hardware. Compare the profiles side by side first (GET /api/admin/profiles/compare, rendered as a table in the admin UI). The optional[mining.auto_profile]switcher exists but is off by default for exactly this reason — see the Configuration Reference.
What the OSS stack ships
Applies to all tiers:
- Stratum V1 plain (
:3333) + TLS (:3334), Stratum V2 over NOISE NX (sv2_listen,:3336in the reference deployment) - Bitcoin Core solo mining (full reward to the block finder), via
getblocktemplate+ ZMQ by default, or opt-in via Core’s IPC interface ([node] template_source = "ipc", Core ≥ 31 — Core’s IPC interface is itself experimental) - Multi-node failover: any number of
[[node.backup]]nodes, withsubmitblockbroadcast to all of them in parallel (Resilience) - Automated JSON ledger snapshots (
[backup]) — no secrets, no raw shares - Per-worker payout mode — a public, non-custodial, 0%-fee solo pool where
every miner mines to its own address (from the Stratum login), plus login-free
wallet pages at
/users/<address>. Because the pool never holds funds and shares no reward, this is a public deployment that stays inside the OSS scope — the commercial line below is about custodial / reward-sharing pools, not about being public. See Public 0% Solo Pool. - Class-aware VarDiff (Bitaxe / Antminer / Avalon Q / Avalon ASIC / NerdMiner / NerdNOS / Whatsminer)
- Admin UI with Argon2 passwords, optional 2FA, signed auto-update channel, and
API tokens with an
adminor read-onlyreadscope - 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 (ntfy, Telegram, Discord, Slack, email, Web Push) for block-found + health-alert events
- Distribution: Linux .deb / .rpm / AppImage, multi-arch container images from
the project’s own registry at
git.warppool.org, Umbrel community store, plus a macOS .dmg (built manually per release — there is no macOS build runner). Windows .msi is paused (no Windows build runner)
For Solo and Solo + Friends that stack is ready for solo/friends use. 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 — what a community pool needs on top
The items below were originally listed here as gaps. Most have since shipped as opt-in core hardening; what remains is operator-supplied infrastructure rather than missing code.
| Item | Status |
|---|---|
| Per-IP connection cap | Shipped (v1.13.0) — [stratum] max_connections_per_ip |
| Connect/auth rate-limit + escalating auto-ban | Shipped — [ratelimit] with autoban_* |
| Reject-ratio ban (share-flood defence) | Shipped (v1.13.0) — [ratelimit] reject_ban_* |
| Subnet access control | Shipped — [ratelimit] ban_cidrs / allow_only / allow_cidrs |
| Stratum whitelist / registration | Shipped as an address allowlist — [mining] address_allowlist (an invite-only per-worker pool); full member registration with accounts is still out of scope |
| Worker / address disclosure gating | Shipped — [privacy] wallet_directory_public masks addresses in /api/workers and the wallet directory for anonymous visitors (v1.19.1), leaderboard_addresses_public masks the leaderboard. /api/workers carries no peer IPs at all. Daemon logs still contain peer IPs — treat them as personal data |
| Automated DB backup | Shipped (v1.13.0) — [backup] (enabled, interval_secs, dir, keep) writes JSON ledger snapshots. It contains no secrets and no raw shares |
| Reverse proxy with TLS, rate-limit + access-log | Operator-supplied software, but fully documented: ready-to-paste nginx/Caddy configs in Reverse Proxy. Pair it with [server] trust_proxy_headers = true so per-IP limits see the real client IP |
See Security and the Configuration Reference for every key, and Public 0% Solo Pool for a ready-made public hardening profile.
Tier 4 — custodial / service gaps (in addition to Tier 3)
| Gap | Why it matters at this scale |
|---|---|
| DDoS mitigation | Cloudflare TCP pass-through or dedicated providers; a connection cap alone is not protection |
| PPLNS / FPPS payout engine | OSS is pure solo (100% of the reward to the finder). Reward sharing means custody, which is exactly the AGPL boundary: it lives in a separate commercial sidecar, not in this repo |
| Multi-admin + role-based access | A single admin user, plus read-only API tokens. Full per-user roles and per-user audit are not in OSS |
| T&C / Privacy templates | EU operators need a GDPR-compliant notice for worker-IP processing; liability if a found block is lost. The [legal] config block renders links to your own documents — it does not supply the texts |
| Monitoring stack | /metrics is exposed (Prometheus format), but the scrape config, alerting beyond the built-in notifier, and 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 |
Not a gap any more: the public stats page. The dashboard is viewable anonymously (hashrate, active miners, block history), and a per-worker pool additionally serves login-free wallet pages at
/users/<address>plus ckpool-compatible JSON at/api/users/<address>. How much of that anonymous visitors see is controlled by[privacy].
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 an issue in this repo at git.warppool.org/dvb-projekt/dvb-WarpPool/issues:
- Tier 3 (Community Pool): label
community-pool - Tier 4 (custodial / 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.
The announcement banner is not configured here. The marquee strip under the header is runtime state in the database, not a
config.tomlkey — see Announcement banner.
[legal]
[legal]
terms_url = "https://example.com/terms"
privacy_url = "https://example.com/privacy"
privacy_last_updated = "2026-02-14"
Optional. All fields default to empty. When empty (the solo default), the UI
shows no legal notice. When a public / commercial operator sets terms_url
and/or privacy_url, the dashboard footer renders a “By connecting mining
device(s) or software to this pool, you acknowledge…” notice linking to those
URLs (with the optional privacy_last_updated stamp). The legal texts
themselves are the operator’s responsibility. URLs are restricted to
http/https.
[privacy]
[privacy]
wallet_directory_public = true # false → Workers directory is operator-only
leaderboard_addresses_public = true # false → mask addresses in the best-shares leaderboard
Optional. Both default to true = the historical open behaviour, so existing
deployments are unchanged on upgrade. They control how much a public pool
exposes, matching the convention of established solo pools:
wallet_directory_public = falsehides the browsable Workers/wallet directory (/api/walletsand its nav link) from anonymous visitors — likesolo.ckpool.org, which has no public directory at all. Each per-address page/users/<address>stays reachable by direct link, and the authenticated operator still sees the full directory. Note: for the operator to keep seeing it, admin auth must be enabled (an unauthenticated request — including the operator’s own browser without a session — is treated as public).leaderboard_addresses_public = falsemasks the Bitcoin address in the public best-shares leaderboard server-side (e.g.bc1qsx…dvng, rig suffix dropped) — like AtlasPool. The authenticated operator still sees full addresses.
Neither affects payouts, block construction, or the per-miner opt-in privacy
flag (an address owner can already hide themselves via the signature-gated Owner
Area on their /users/<address> page); the two mechanisms are orthogonal.
[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_mode = "single" # "single" (default) | "per_worker"
payout_address = "bc1q..." # REQUIRED in "single" — 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" # max 64 bytes, written to coinbase scriptSig
operator_donation_address = ""
operator_donation_percent = 0.0
# Address-allowlist (Kern-Politur): payout addresses allowed to mine. Empty
# (default) = open to anyone. For a private / invite-only pool.
address_allowlist = ["bc1qalice...", "bc1qbob..."]
payout_mode — where the block reward goes:
"single"(default): the full reward goes to the single pool-widepayout_address. This is the ordinary solo / friends setup and is 100% backward-compatible."per_worker": each miner mines to their own address, taken from the Stratum login (<address>.<worker>). The pool never holds funds — a public, non-custodial 0%-fee solo pool. In this modepayout_addressis ignored and may be empty, andpool_fee_percent/operator_donation_percentmust be 0 (the full reward goes to the finder). A miner whose login is not a valid address for this network is rejected. Seedocs/design/per-worker-coinbase.md.
For multi-user-solo (hosting-service setup) in "single" mode: set
pool_fee_percent = 1.0 and pool_fee_address. The sum of fee + donation must
not exceed 99%.
When address_allowlist is non-empty, a miner whose payout address (everything
before the first . of the worker name) is not listed is rejected — so only your
own / invited miners can connect. Empty = open (the default). This gates both
transports: V1 mining.authorize, and Stratum-V2 channel-open (rejected with an
OpenMiningChannelError). On V2 a miner additionally needs the pool’s NOISE
authority key to connect at all.
Public wallet pages & the owner panel have no config. In per_worker mode
each miner gets a login-free page at /users/<address> (ckpool-compatible JSON
at GET /api/users/<address>). The opt-in owner panel — prove address ownership
by signing a BIP-137 challenge, then toggle privacy or set a per-wallet
notify target — is driven entirely per miner and is only reachable after
that miner verifies ownership; there is no operator key here to set. Do not
confuse this with two unrelated settings above: [legal] privacy_url is a
legal-footer link for the whole pool (terms/privacy policy), not a per-wallet
control, and address_allowlist gates who may mine, not who may see a page.
See Public 0% Solo Pool
and Notifications.
[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
template_source = "gbt" # "gbt" (default) | "ipc" — see below
ipc_socket_path = "" # required when template_source = "ipc"
ipc_fee_delta_sats = 0 # ipc only; 0 = off
The cookie takes precedence over user/pass in secrets.toml. Leaving the
ZMQ fields empty enables poll-only mode (slower but functional).
template_source picks where block templates come from:
"gbt"(default) —getblocktemplateover RPC, refreshed on ZMQ block events plus thejob_refresh_secspoll. Works with every Bitcoin Core release."ipc"— the Cap’n-Proto mining interface of a multiprocess Bitcoin Core ≥ 31 (bitcoin-node -ipcbind=unix), which pushes a new template viawaitNextinstead of being polled. Opt-in, and Core’s own IPC mining interface is itself experimental — treat this mode as a prototype and keep"gbt"for an unattended pool.
RPC stays mandatory in both modes (submitblock, health checks,
mintime). Config validation rejects any other value, and rejects
template_source = "ipc" without an ipc_socket_path.
ipc_socket_path is the -ipcbind Unix socket; Core’s default is
<datadir>/<network>/node.sock and a unix: prefix is accepted. Being a Unix
socket, pool and node must share a host or a volume (on Umbrel: the Bitcoin app
from v1.3.0 with the IPC Mining Interface toggle and Core v31.0 — mount the
datadir read-only and resolve the socket inside it).
ipc_fee_delta_sats (ipc only) additionally pushes a fresh template as soon as
the template’s fees rise by at least that many sats. 0 (default) = off, which
keeps the exact GBT cadence (tip change + job_refresh_secs poll). Values above
2100000000000000 (21 M BTC in sats) are rejected.
Both fields are also settable from the UI under Admin → Bitcoin Core → Template source, including a socket precheck.
[[node.backup]] — multi-node failover
Optional: any number of backup Bitcoin Core nodes. The [node] block is
the primary; each [[node.backup]] entry adds a fallback:
[[node.backup]]
rpc_url = "http://192.168.178.99:8332"
rpc_cookie_path = "/mnt/node-b/.bitcoin/.cookie" # auth option 1
zmq_hashblock_addr = "tcp://192.168.178.99:28332" # empty = no ZMQ from this node
Per-backup auth resolution, in order: rpc_cookie_path → user:pass
embedded in rpc_url (http://user:pass@host:8332 — note this lives in
config.toml, mind the file permissions) → the global
secrets.rpc_user/rpc_pass.
Failover semantics:
- Sticky with preferred primary. Calls run against the last working node; on transport, auth or warmup errors they hop to the next within the same call (the job loop never stalls). While running on a backup, roughly once a minute one call probes the primary first and switches back as soon as it answers.
submitblockgoes to ALL nodes in parallel — the block moment is the one instant where a dead node can cost real money; anyAcceptedwins.- ZMQ subscribes to every node that has a
zmq_hashblock_addr; the job loop deduplicates block events by hash, so whichever node reports first triggers the refresh. - Notifications: a node switch fires
node-failover(delivered to sinks that subscribed toon_rpc_down);rpc-downitself now means “no node reachable at all” — a pool running happily on a backup is degraded, not down, and the health snapshot carries a warning instead.
Verified live: primary kill → failover < 1 s with the job stream uninterrupted; primary restart → automatic return ≤ 60 s.
[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
cookie_secure = false # set true when the UI is served via HTTPS
# job_refresh_secs = 90 # template poll interval, range 10-600 (default 90)
# suppress_health_warnings = ["bitcoin_pruned"] # hide these health-banner warnings
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.
job_refresh_secs overrides the --refresh-secs CLI argument and accepts
10–600 seconds. The default of 90 s is deliberate: every mining.notify makes
an ESP32-class miner (NerdMiner V2, ~250 KH/s) abort its hash loop and reload
the coinbase, which resets its displayed hashrate. ASIC miners don’t feel the
trade-off either way.
suppress_health_warnings is an opt-in list of Bitcoin Core health-warning
keys the dashboard banner should not show — handy for a deliberately pruned
node (bitcoin_pruned). It is only the initial value: on first start it seeds
a database-backed list that you then manage under Admin → Health (or via
the “Hide permanently” button on the banner). Known keys include
bitcoin_pruned, bitcoin_no_inbound_listen, bitcoin_no_zmq_hashblock and
bitcoin_few_peers.
Running behind nginx or Caddy? See Reverse Proxy for
ready-to-paste configs (TLS termination, X-Forwarded-For, and the
/api/events SSE passthrough) and how trust_proxy_headers / cookie_secure
fit in.
[stratum]
[stratum]
stratum_tls_listen = ":3334" # 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"
tls_reload_secs = 300 # re-read cert/key on mtime change; 0 = off
sv2_listen = ":34254" # optional, Stratum V2
sv2_max_connections = 1024
max_connections_per_ip = 0 # V1 per-IP concurrent cap; 0 = off (Kern-Politur)
max_connections_per_ip bounds how many simultaneous V1 connections (plain +
TLS) a single source IP may hold — independent of the global connection cap.
0 (default) is off; loopback is always exempt. Useful on a public pool to stop
one host from hogging connection slots, while NAT’d home setups (many miners
behind one IP) usually leave it at 0.
Sv2 needs an authority key in secrets.sv2_authority_priv_key_hex
(32-byte x-only secp256k1, 64 hex chars). Since v1.0.15 the daemon
auto-generates and persists it on first start; only when the secrets
file is not writable is Sv2 disabled with a warning (the daemon still
starts).
When tls_cert_path/tls_key_path are unset, the daemon generates and
reuses a self-signed certificate under <data>/tls/ (SANs:
dvb-warppool, dvb-warppool.local, umbrel.local, localhost;
downloadable via GET /api/tls-cert and from the connect modal). Point
the two paths at your own files to serve a CA-signed certificate
instead — needed for miner firmwares that only trust public CAs (see
Troubleshooting → TLS).
Renewal is handled by whatever issued the certificate (Caddy, certbot, your
PKI). The daemon watches both files and re-reads them when their mtime
changes, so an ACME renewal takes effect without a restart and without
dropping miners: existing connections keep the certificate they negotiated,
new handshakes get the fresh one. tls_reload_secs sets how often the check
runs (default 300 s; 0 disables it and restores the old
read-once-at-startup behaviour). If a reload fails — a half-written file
mid-renewal, say — the previous certificate stays in service and the next
check retries, so a bad write never takes the listener down.
[tuning]
[tuning]
max_saved_workers_per_user = 64
max_saved_workers_pool_total = 1024
active_worker_window_secs = 3600 # dashboard "active" window (default 1h)
Storage cap. Above these thresholds, old/idle workers are evicted (FIFO
by last_seen_at). active_worker_window_secs controls how long a
worker keeps counting as “active” on the dashboard after its last share.
[mining.auto_profile]
[mining.auto_profile]
enabled = false # opt-in
evaluation_interval_secs = 60
hysteresis_ticks = 5 # consecutive ticks before a switch
Automatic profile switching based on the active miner count. Disabled by default; the admin UI’s manual hot-switch always works regardless.
[profile]
[profile]
kind = "klein" # klein|mittel|gross|enterprise (Small/Medium/Large/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.
A profile switch also applies that profile’s VarDiff range
(initial_diff / min_diff / max_diff, shown per profile on the
Hardware page) to the running pool — the change takes effect for new
connections immediately and is persisted, so a restart derives the same
values. Earlier this was decoupled: the table showed a profile’s VarDiff
but the live pool kept the [vardiff] config values. The other VarDiff
fields (target_seconds_per_share, window, hysteresis, max_step,
idle_*) are not touched by a profile switch. If you set [vardiff]
values by hand in config.toml and never switch profiles at runtime,
your hand-set values stand.
Beyond VarDiff and the connection cap, a profile switch also drives the miner-poll interval, the retention TTLs, the Stratum-auth rate limit and the HTTP-API rate limit — live, and persisted so a restart re-derives them. The Hardware comparison table labels each row with which behaviour applies:
| Badge | Meaning | Values |
|---|---|---|
live | applies immediately, no restart | connection cap, VarDiff range, miner-poll interval, retention TTLs, Stratum-auth & API rate limits |
restart | takes effect on the next daemon start | db_pool_size (SQLite pools can’t resize live) |
guide | advisory only — a switch does not change it | stratum_threads / share_validator_threads (fixed at runtime), ram_target_mb (a hint), ws_update_interval (a UI-poll guide; the backend pushes via SSE) |
The same honesty rule as VarDiff applies to retention and the rate limits:
hand-set config.toml values for [retention] / [ratelimit] stand as long
as you never switch profiles at runtime. Retention maps the profile’s four
tiers (raw_shares / agg_5min / telemetry_raw, with agg_5min also
driving the 5-minute telemetry tier) onto the live eviction; the operator-only
fields (audit_log_secs, api_tokens_retired_secs,
push_subscriptions_stale_secs) are never touched by a switch. agg_1h has no
TTL eviction — those rows are rolled into daily_aggregates.
Profile defaults at a glance
The four profiles each bundle a set of presets for a pool size. This is the
same table shown on the Hardware page (where the active profile’s column is
highlighted). The Scope column is the live / restart / guide behaviour from
the badge table above. The headers below use the profiles’ English display
names; the matching config kind values are klein (Small), mittel
(Medium), gross (Large), and enterprise.
| Setting | Scope | Small | Medium | Large | Enterprise |
|---|---|---|---|---|---|
| Recommended miners | — | ≤5 | ≤20 | ≤100 | >100 |
| Deployment | — | Solo | Solo + friends | Community¹ | Service¹ |
| Connection cap | live | 16 | 64 | 256 | 4,096 |
| RAM target | guide | 128 MB | 512 MB | 2 GB | 8 GB |
| Stratum threads | guide | 1 | 2 | all cores | all cores |
| DB pool | restart | 4 | 8 | 16 | 32 |
| UI refresh | guide | 5 s | 2 s | 1 s | 1 s |
| Miner polling | live | 30 s | 15 s | 5 s | 1 s |
| VarDiff initial | live | 4,096 | 32,768 | 1,048,576 | 16,777,216 |
| VarDiff min | live | 1,024 | 4,096 | 65,536 | 524,288 |
| VarDiff max | live | 262,144 | 4,194,304 | 67,108,864 | 1,073,741,824 |
| Retention — raw shares | live | 1 d | 7 d | 30 d | 90 d |
| Retention — 5-min agg | live | 7 d | 30 d | 90 d | 1 y |
| Retention — 1-h agg | guide | 90 d | 1 y | 5 y | 10 y |
| Retention — miner telemetry | live | 1 d | 7 d | 30 d | 90 d |
| Stratum auth / min | live | 60 | 120 | 600 | 6,000 |
| API / min | live | 120 | 600 | 3,000 | 60,000 |
¹ Community and Service assume commercial extensions outside the OSS scope — see Scaling Tiers.
What each row means:
- Connection cap — maximum simultaneous miner connections; beyond it new ones are refused.
- RAM target — a rough memory guideline for the size class, not a hard cap (the pool does not pre-allocate it).
- Stratum threads — Tokio worker threads for the Stratum listener
(
all cores=num_cpus); fixed when the runtime starts. - DB pool — SQLite connection-pool size (more = more concurrent DB access);
fixed at startup, hence
restart. - UI refresh — a guideline for how often the dashboard updates; the backend pushes live events over SSE anyway, so it is advisory only.
- Miner polling — how often the pool polls each miner’s telemetry (temperature / watts / volts).
- VarDiff initial / min / max — the per-profile defaults the running VarDiff
starts from and stays within. Sub-1 difficulty stays possible for tiny devices
via
suggest_difficulty, regardless ofmin. - Retention — how long each data class is kept before automatic eviction; larger profiles keep more history.
- Stratum auth / API per min — per-IP rate limits; loopback and the LAN are
exempt (see
[ratelimit]).
[vardiff]
[vardiff]
enabled = true
target_seconds_per_share = 15.0
window = 8
min_diff = 1.0
max_diff = 65536.0
retarget_after_n_shares = 4
hysteresis = 0.20 # 20% — a new diff that deviates > 20% from the old one triggers a retarget
max_step = 4.0 # max 4× per retarget
initial_diff = 1.0
idle_decay_enabled = true # time-driven decay for workers that went quiet
idle_grace_factor = 20.0 # × target_seconds_per_share of silence before the first decay step
idle_step_factor = 8.0 # × target between further steps while still quiet
significance_gate = true # only retarget when the deviation is statistically significant (default on since v1.11.0)
gate_confidence = 0.90 # confidence band of the significance gate
EMA-based, with persisted snapshots in the vardiff_state table. Tests:
crates/stratum-v1/src/vardiff.rs.
Significance gate. significance_gate (on by default) makes VarDiff
retarget only when the EMA share-rate deviation is statistically significant for
the number of shares seen — formally when
|ema/target − 1| · √window > z_crit(gate_confidence). A worker producing few
shares per window (NerdMiner, NerdNOS and other sub-MH/s / ESP-class miners) has
a Poisson-noisy observed rate (≈ 1/√N), and a fixed hysteresis band reads that
noise as a real hashrate change — flapping the difficulty and flooding the miner
with mining.set_difficulty (the visible ESP32 display flicker). The gate holds
the difficulty until the deviation is real; a genuine hashrate change still
clears the band promptly (verified live: a 4× change reacts within ~1 share). Set
significance_gate = false to retarget purely on the hysteresis band (tighter
tracking, more churn). gate_confidence (default 0.90) sets the band — higher =
stricter = fewer retargets.
Idle decay. Share-driven retargeting has a blind spot: a worker whose
difficulty ended up too high (snapshot from a beefier predecessor device
under the same worker name, thermal throttling, a misdetected class) stops
producing shares — and without shares there is nothing to trigger the
downward retarget. The idle decay closes that loop on a timer: after
idle_grace_factor × target_seconds_per_share of silence (default
20 × 15 s = 5 min — the chance a correctly-tuned worker is randomly silent
that long is e⁻²⁰), the difficulty steps down by max_step (÷4), then
again every idle_step_factor × target until shares flow again or the
class min_diff floor is reached. Three guards make this safe against the
classic crush bug (M45-goPool#32): the idle clock starts with the session
— never from a persisted timestamp, so a reconnecting miner is never
punished for the downtime; steps are gradual, never straight to the floor;
and the floor is a plain clamp, no formula artifacts. Workers pinned via
d= are exempt — an explicit request outranks the automation.
Miner difficulty requests are honored, clamped to the hardware class. Both standard V1 mechanisms work out of the box, no config needed:
mining.suggest_difficulty(d)— taken as the starting point: the pool setsdimmediately, VarDiff stays active and corrects later if the observed share rate doesn’t fit (the spec defines suggest as a hint, not a contract). Works before or aftermining.authorize.d=<x>in the authorize password field (e.g. passwordx,d=8192) — pins the difficulty for the session: VarDiff retargeting is disabled, the miner said explicitly what it wants. Useful for devices whose worker name field has a character whitelist (Avalon Q) and for proxy setups.
Both are clamped to the class-tuned [min_diff, max_diff] band — a
NerdMiner can’t request 1M (it would starve), an S21 can’t request 0.001
(it would flood the pool). Garbage values (0, negative, non-numeric) are
acknowledged but ignored; a hint must never break the session. d= beats
suggest; both beat the persisted snapshot from a previous connection.
[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
# Auto-ban: a public IP that keeps tripping the connect limit gets a full,
# escalating ban instead of just being throttled. Loopback + private LAN are
# never banned (local miners can reconnect in bursts).
autoban_enabled = true
autoban_strikes = 10 # connect-limit violations within the window …
autoban_window_secs = 60 # … that trigger a ban
autoban_base_secs = 300 # first ban 5 min; doubles per repeat …
autoban_max_secs = 3600 # … capped at 1 h
# Reject-ratio ban (Kern-Politur) — ban a worker that floods bad shares.
reject_ban_enabled = false # off by default
reject_ban_ratio = 0.5 # rejected/(accepted+rejected) over this …
reject_ban_min_samples = 50 # … once at least this many shares are seen → ban + drop
# Manual (permanent) ban list — operator-curated. Also editable live via the
# admin API / the Admin → Profile page. Applies to any IP except loopback.
manual_bans = ["203.0.113.7", "198.51.100.42"]
# IP-gate (Kern-Politur) — subnet-level access control, off by default.
ban_cidrs = ["203.0.113.0/24"] # deny whole ranges (CIDR or bare IP)
allow_only = false # true = strict whitelist (only allow_cidrs connect)
allow_cidrs = ["192.168.0.0/16"] # the whitelist (used only when allow_only = true)
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). Auto-ban goes one step further for public IPs: after
autoban_strikes connect-limit violations within autoban_window_secs, the IP
is rejected outright (before TLS/handshake) for an escalating duration. Set
autoban_enabled = false to keep pure throttling. Loopback and private-LAN
addresses are exempt by design, so your own miners are never locked out.
The IP-gate adds subnet-level control on top of the single-IP manual_bans:
ban_cidrs refuses whole ranges, while allow_only = true flips to a strict
whitelist where only allow_cidrs (plus loopback) may connect — handy for a
private / invite-only pool. Both take CIDR ranges or bare IPs; malformed entries
are dropped (logged) at start; loopback always passes so you can’t lock yourself
out. All off by default.
Reject-ratio ban catches a different abuser: a worker that connects fine but
floods invalid shares. With reject_ban_enabled = true, once a session has sent
at least reject_ban_min_samples shares and its rejected fraction is ≥
reject_ban_ratio, that (public) IP is banned for the escalating auto-ban
duration and the connection dropped. Public-IP-only, same as the connect
auto-ban — your own LAN/loopback miners are never caught.
[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
on_health_alert = true
[notifier.telegram] # supports on_block_found + on_health_alert only
bot_token_env = "TELEGRAM_BOT_TOKEN"
chat_id = "123456789"
on_block_found = true
on_health_alert = true
[notifier.discord] # supports on_block_found + on_health_alert only
webhook_url_env = "DISCORD_WEBHOOK_URL"
on_block_found = true
on_health_alert = true
[notifier.slack]
webhook_url_env = "SLACK_WEBHOOK_URL"
on_block_found = true
on_miner_disconnect = false
on_rpc_down = true
on_health_alert = 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_miner_disconnect = false
on_rpc_down = true
on_health_alert = 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.
[[webhooks]]
Generic outbound webhooks: the pool POSTs a JSON payload to each
configured URL whenever a matching pool event fires. This is the push
counterpart to the /api/events SSE pull stream — see
Add-ons & Sidecars for when to use which. Repeat the block for
multiple targets; omit it entirely and nothing is sent.
[[webhooks]]
url = "http://127.0.0.1:9000/ingest" # http:// or https:// ONLY
secret_env = "WARPPOOL_WEBHOOK_SECRET" # optional; enables HMAC signing
# events = [...] # omit → default low-frequency set
enabled = true # default true
# A second target, opting into high-frequency events:
[[webhooks]]
url = "https://example.com/hook"
events = ["block_found", "health_snapshot", "new_job", "shares_accepted"]
| Field | Required | Description |
|---|---|---|
url | yes | Target URL. Only http:// / https:// are accepted. |
secret_env | no | Name of the env var holding the HMAC secret (never inline). When set, every request carries X-WarpPool-Signature: sha256=<hex>. |
events | no | List of event type strings to deliver. Empty → default set (low-frequency only). |
enabled | no | Default true. Set false to keep the config but stop sending. |
Payload (Content-Type: application/json):
{ "event": "block_found", "ts": "2026-06-19T12:00:00+00:00", "data": { /* the full event object */ } }
Headers: X-WarpPool-Event: <type> always; X-WarpPool-Signature: sha256=<hmac>
only when secret_env is set. Verify the signature exactly like a GitHub webhook
— HMAC-SHA256(secret, raw_request_body), compared in constant time.
Default event set (when events is empty) — low-frequency only, so a webhook
never causes a POST storm out of the box:
block_found,health_snapshot,update_available,profile_switched
High-frequency events — new_job and shares_accepted fire constantly (a
new_job per template, an aggregated shares_accepted up to once a second). Only
list them if your consumer can handle the volume; otherwise prefer the default
set or the SSE stream. (heartbeat is also available but rarely useful for a
webhook.)
Delivery semantics: each POST has a ~10 s timeout and is retried with
exponential backoff (3 attempts total). If all attempts fail the event is logged
(webhook dropped after retries) and dropped — there is no unbounded buffer,
so a down consumer cannot back up the pool.
No SSRF protection — by design. Loopback / LAN targets are explicitly allowed because local sidecars are the main use case. The URL comes from this operator-trusted config, so only the URL scheme is validated. Do not point a webhook at an untrusted URL.
[retention]
[retention]
shares_raw_secs = 3600 # per-share rows before aggregation (1 h)
shares_agg5_secs = 604800 # 5-min share buckets (7 d)
miner_telemetry_raw_secs = 3600 # per-poll vendor probe results (1 h)
miner_telemetry_agg5_secs = 604800 # 5-min telemetry buckets (7 d)
workers_secs = 7776000 # idle worker rows — block-finders kept forever (90 d)
audit_log_secs = 7776000 # admin actions / logins / token mints (90 d)
api_tokens_retired_secs = 7776000 # grace AFTER revoke/expiry before the row is dropped (90 d)
push_subscriptions_stale_secs = 7776000 # grace after the LAST failed push attempt (90 d)
All values are TTLs in seconds; eviction runs inside the daemon’s
aggregate loop (raw/agg tables every minute, audit/tokens/push hourly).
Defaults match the values that were hardcoded before the block existed —
omitting the section changes nothing. workers_secs bounds the one table a
client can grow by reconnecting under fresh worker names: idle worker rows are
evicted after the TTL, but any worker that ever found a block is kept forever.
Two entries are conditional rather
than age-based: active API tokens never age out
(api_tokens_retired_secs counts from revocation or expiry), and push
subscriptions are only dropped when their most recent delivery attempt
failed (healthy but rarely-pushed subscriptions survive — on a solo pool
the next real push may be months away; 410 Gone endpoints are deleted
immediately by the push path itself).
[upnp]
[upnp]
enabled = false # default off — opt-in
renew_interval_secs = 3000 # 50 min, safely under the default 3600 s lease
[[upnp.forwards]]
port = 3333
protocol = "tcp" # "tcp" or "udp"
lease_seconds = 3600 # lease requested on every renew (60..=86400)
description = "dvb-WarpPool Stratum V1"
Phase 27: the setup wizard opens forwards once with a 1 h lease; if the
pool runs longer, the daemon has to renew them. With enabled = false
(default) no UPnP discovery task is started at all. Renewals are
idempotent on the router side; the first renew tick runs ~10 s after
boot so a crashed daemon recovers its forwards quickly.
[backup] — in-daemon auto-backup
[backup]
enabled = false # master switch (default off)
interval_secs = 86400 # seconds between backups (clamped to >=60); default daily
dir = "backups" # output directory (created if missing; relative to CWD)
keep = 7 # keep the newest N snapshots, prune older; 0 = keep all
When enabled = true the daemon writes a JSON ledger snapshot — the exact same
format as GET /api/admin/backup (workers, blocks, miners, vardiff, settings;
no secrets or raw shares) — to dir every interval_secs, named
warppool-backup-<timestamp>.json, and prunes to the newest keep. Off by
default: operators who already cron the API endpoint (or back up the SQLite file
externally) simply leave it disabled. Note this is unrelated to
[[node.backup]], which is Bitcoin-node failover.
[mining.electricity]
Note the path: electricity lives inside [mining], so the table headers are
[mining.electricity], [[mining.electricity.tou]] and
[mining.electricity.solar] — a bare [electricity] block is ignored.
[mining.electricity]
default_rate_eur_kwh = 0.30 # 0.0 = no tariff known, watt tracking only
[[mining.electricity.tou]] # optional time-of-use slots, first match wins
name = "HT"
rate_eur_kwh = 0.35
hours = "06:00-22:00" # wrapping slots like "22:00-06:00" work
weekdays = ["mon", "tue", "wed", "thu", "fri"] # empty/omitted = every day
[mining.electricity.solar]
enabled = false
kind = "home_assistant" # or "anker", "generic_http"
url = "http://127.0.0.1:8765/snapshot.json" # direct URL (sidecars); takes precedence over url_env
url_env = "WARPPOOL_HA_URL" # env var holding the provider base URL (cloud endpoints)
token_env = "WARPPOOL_HA_TOKEN" # auth token env var (HA long-lived token, ...)
pv_entity_id = "sensor.pv_power_total" # required for kind = "home_assistant"
consumption_entity_id = "sensor.house_power" # optional; omitted → compare against pool power
poll_interval_secs = 60
excess_rate_eur_kwh = 0.0 # rate while PV excess covers the pool
surplus_buffer_w = 200.0 # excess must exceed pool power by this margin (anti-flap)
stale_after_secs = 300 # snapshot older than this → fall back to TOU/default
Drives the cost figures on the energy card: the effective rate per
5-minute bucket is solar-excess (if active), else the first matching
TOU slot, else default_rate_eur_kwh. Solar provider credentials for
the daemon-managed Anker bridge live in secrets.toml
(anker_email/anker_password/anker_country), not here.
Announcement banner (runtime, not config.toml)
Since v1.21.0. The scrolling announcement strip under the public pool header (“maintenance tonight 22:00”, “welcome, new miners”) is runtime state, not a config key: it is set from Admin → Banner or over the API, takes effect without a restart, and expires by itself.
| Endpoint | Auth | Does |
|---|---|---|
GET /api/admin/banner | admin | Returns the stored text, expires_at and a computed active flag. |
POST /api/admin/banner | admin | Starts an announcement. Body: {"text": "...", "duration_secs": 3600}. |
DELETE /api/admin/banner | admin | Ends it early. Clearing an already-empty banner is a no-op, not an error. |
POST validation — all violations return 400:
| Rule | Limit |
|---|---|
text non-empty after trimming | required |
text length | ≤ 200 characters |
text control characters | rejected (they would break the marquee) |
duration_secs | 60 s … 90 days |
The text is stored and rendered as plain text (the UI writes it via
textContent, never as HTML).
Persistence is two rows in the pool_settings key-value table:
| Key | Value |
|---|---|
banner_text | the announcement text (empty ⇒ no banner) |
banner_expires_at | RFC3339 timestamp, computed as now + duration_secs |
Because the expiry is stored rather than scheduled, the banner self-clears: a
missing, unparseable or already-passed banner_expires_at simply yields no
banner.
Public read path: while active, GET /api/overview carries a
banner object {"text": "...", "expires_at": "<rfc3339>"}. The field is
absent when there is no active banner — consumers must treat it as
optional, not as an empty string. The UI additionally hides the strip locally
at expires_at, so it disappears on time instead of at the next overview poll.
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_authority_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.
Credentials written by the admin UI
These are not meant to be hand-edited — the admin pages write them here, and
none of them is ever returned by the API. They exist so the operator does not
have to plant env vars by hand: on start the daemon hydrates the env vars that
the [notifier] sink configs reference (bot_token_env and friends) from
these values.
notifier_telegram_bot_token = "..." # via Admin → Notifications
notifier_discord_webhook_url = "..."
notifier_slack_webhook_url = "..."
notifier_smtp_password = "..."
anker_email = "you@example.com" # via Admin → Profile → Photovoltaik
anker_password = "..." # GET /api/admin/solar only reports anker_creds_set: bool
anker_country = "DE" # ISO-3166, default "DE"
With solar.kind = "anker" plus email and password set, the daemon starts the
Anker bridge itself as a child process — no separate launchd/systemd service
needed.
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 the forge (git.warppool.org) 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.
Supported kind values: anker (daemon-managed Anker Solix bridge —
enter your Anker credentials under Admin → Photovoltaik, the pool runs
the bridge itself), home_assistant (REST API, shown below), and
generic_http (any endpoint serving the snapshot JSON).
[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. |
Deployment / container overrides
Set by the Docker and Umbrel packaging; rarely touched on a bare-metal install.
Every WARPPOOL_* flag below is truthy for exactly 1, true or yes.
| Var | Default | Description |
|---|---|---|
DVB_WARPPOOL_STRATUM_TLS_LISTEN | (unset) | Overrides stratum.stratum_tls_listen. The --no-tls CLI flag still wins. |
DVB_WARPPOOL_SV2_LISTEN | (unset) | Overrides stratum.sv2_listen. |
DVB_WARPPOOL_DEPLOY | (unset) | umbrel / docker — tells the daemon it runs in a container, so it stops looking for a host bitcoin.conf and reports Bitcoin Core as external. |
DVB_WARPPOOL_BITCOIN_DATA_MOUNT | /bitcoin | Where the Bitcoin datadir is mounted; used to resolve the IPC socket. |
WARPPOOL_DATA_DIR | (platform default) | Overrides the data directory used for the install marker. |
WARPPOOL_DISABLE_MDNS | (off) | Turns mDNS miner discovery off entirely. |
DVB_WARPPOOL_ANKER_BRIDGE | /opt/dvb-warppool/anker-bridge/anker-bridge.py | Path to the Anker bridge script. |
DVB_WARPPOOL_ANKER_REPO | /opt/dvb-warppool/anker-solix-api | Checkout of the anker-solix-api library. |
DVB_WARPPOOL_ANKER_PYTHON | (auto) | Python interpreter for the bridge; without it, venv detection with a python3 fallback. |
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 \
--data-dir /var/lib/dvb-warppool \ # DB lives at <data-dir>/warppool.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) — resizes the connection cap, the VarDiff range, the miner-poll interval, the retention TTLs and the rate limits live (see thelive/restart/guidetable above) - Announcement banner (
POST/DELETE /api/admin/banner) — effective immediately, and self-expiring - Manual IP bans (
POST /api/admin/ratelimit/ban) — effective immediately - Suppressed health warnings (Admin → Health) — DB-backed after first start
- Notifier credentials (
POST /api/admin/notifier/config) — written tosecrets.toml - 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 diagnostic of:
- Bitcoin Core health — everything reachable over RPC (
/api/bitcoin-health) - Connectivity snapshot — RPC + ZMQ endpoint reachability, pre-filled from
your
bitcoin.conf(/api/connectivity-snapshot)
Both checks are entirely local — the wizard never contacts a third party.
Privacy matrix
| Check | What it does | Privacy |
|---|---|---|
| Bitcoin health (RPC calls) | Localhost → Bitcoin Core | ✅ Local |
| Connectivity snapshot | TCP-dials the configured RPC + ZMQ endpoints | ✅ Local |
There are no opt-in external probes and no third-party calls. The wizard
reads your bitcoin.conf (when a path is given) purely locally — to pre-fill
the form and to filter the config recommendations.
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 |
Why inbound peers matter for a pool.
peers.inbound = 0is not just a good-citizen nag: a node that nobody can dial reaches the network’s new tip only through its own outbound peers, which adds latency to every block change — and every millisecond your pool keeps handing out a stale job is hashrate spent on a block that can no longer be won. For an internet-facing pool, forward TCP 8333 to the node and keeplisten=1on. The live reference deployment (pool.warppool.org) runs a pruned Bitcoin Core 31.1 with 8333 open, sitting at roughly 30 peers. The warning is skipped when the daemon already sees a LAN peer on 8333 (lan_peer_at_8333), so a node fronted by another host on the same network doesn’t warn spuriously.
Connectivity snapshot
On load (and via “↻ Re-check connection”) the wizard fetches
/api/connectivity-snapshot, which:
- Reads the RPC + ZMQ settings — from your
bitcoin.confif a path is given, otherwise mainnet defaults (:8332) — and pre-fills the form. - TCP-dials each endpoint locally (RPC URL,
zmqpubhashblock,zmqpubrawblock) with a short timeout and reports each as reachable / unreachable / not-set. Nothing is sent beyond the dial. - When no
bitcoin.confis found, it offers a one-click “Create mainnet config” (/api/create-bitcoinconf— writes a config with ZMQ + a random RPC password).
There is no port-bind smoke test, no UPnP discovery, and no external
reachability probe in the wizard. Reachability of your public node is a
Bitcoin Core concern (see the peers.inbound warning above); persistent UPnP
forwarding lives in the daemon ([upnp], see below).
What this looks like in code
Backend in apps/dvb-warppool-setup/src/main.rs:
bitcoin_health(BitcoinHealthReq)— the 5 RPC calls + the warnings (delegates towarppool_health)connectivity_snapshot()— TCP-dials the RPC + ZMQ endpoints and reports each
The warning logic lives in the warppool-health
crate (collect_bitcoin_warnings_v2_with_conf) and has 15 unit tests in
crates/health/src/bitcoin.rs covering every warning edge case (IBD,
low-peers, no-inbound, no-zmq, pruned, verification-progress,
no-double-warning-on-IBD, healthy-zero-warnings, plus the inbound / listen /
private-LAN-peer cases).
UI in apps/dvb-warppool-setup/src/index.html:
- Bitcoin card with RPC test + health-check button
- Connectivity card with per-endpoint status pills + override boxes
- 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)
Daemon UPnP renew loop (Phase 27)
The wizard itself does not open router ports — it has no UPnP feature.
Persistent port forwarding is a daemon concern: an opt-in renew loop in the
daemon 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"
],
"warning_keys": [
{ "key": "bitcoin_few_peers", "message": "…", "params": { "total": "3" } }
]
}
warnings is the legacy array of ready-made strings; warning_keys carries the
same warnings structured (stable key + params) so the UI can localise
them into all supported languages and so an operator can suppress a warning by
key (see below). The UI prefers warning_keys and falls back to warnings.
The UI subscribes via EventSource('/api/events'), dispatches on
event.type === 'health_snapshot', and renders a banner/badge with
the warnings (HealthBanner.svelte).
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 — we push every snapshot; the UI does the client-side compare if needed. Saves server state. (Still true today.)
- No read-only API endpoint
GET /api/admin/health— the UI uses SSE. The only admin health routes that exist are the suppression ones below. A pull endpoint for CLI scripting is still open; for now,/metricscovers scripted monitoring.
Operator example
# Filter the live SSE stream down to health events
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.
Phase 14 — health banner in the dashboard (shipped)
The health_snapshot events are rendered by HealthBanner.svelte as a
persistent banner below the header, localised from warning_keys (falling back
to the raw warnings strings for older daemons). The banner is dismissable
per session on purpose — no localStorage, so a reload brings it back and
nothing gets permanently hidden by accident.
Suppressing a warning permanently
Some warnings are correct but irrelevant for a given deployment — a deliberately pruned node, for example, does not need to be told it is pruned on every reload. A logged-in operator can therefore suppress a warning by its stable key:
- In the UI: Admin → Health, or the “hide permanently” control on the banner.
- Over the API:
GET /api/admin/health/suppressions,POST /api/admin/health/suppress{ "key": "bitcoin_pruned" },DELETE /api/admin/health/suppress{ "key": "bitcoin_pruned" }— all admin-authenticated.
The stable keys are bitcoin_ibd, bitcoin_few_peers,
bitcoin_no_inbound_listen, bitcoin_no_inbound_no_listen,
bitcoin_no_zmq_hashblock, bitcoin_pruned, bitcoin_verification_progress
and ipc_template_stale.
The suppression list lives in the database and is re-read on every health
tick, so changes take effect without restarting the daemon. Suppressed keys are
filtered out before the legacy warnings strings are built, so a suppressed
warning disappears from both fields. Only warnings that carry a key can be
suppressed.
IPC template-stale warning
When the pool takes its templates from Bitcoin Core’s IPC interface
([node] template_source = "ipc" — note that Core’s IPC interface is itself
experimental), the health loop additionally watches whether templates are
still arriving. If none has arrived for longer than 3 job-refresh intervals (at
least 90 s), it raises the suppressible ipc_template_stale warning — a
permanently dead socket (node downgraded below Core 31, IPC toggle off, mount
gone) would otherwise fail silently as “no new jobs”. It is deliberately only a
warning: there is no automatic fallback to GBT; the way back is setting
[node] template_source = "gbt" and restarting.
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
Resilience (Multi-Node)
A solo pool is only as available as the Bitcoin Core node behind it. A single
bitcoind is a single point of failure: if it crashes, falls behind, or is
restarted for an upgrade, the pool can’t build templates — and worst of all, a
node that’s down at the exact second you solve a block can cost you the
reward. dvb-WarpPool lets you run one or more backup nodes so a primary
outage becomes a non-event.
Add a backup node
Backup nodes live in config.toml. The [node] block is your primary; add
any number of [[node.backup]] entries below it:
[node]
rpc_url = "http://127.0.0.1:8332"
zmq_hashblock_addr = "tcp://127.0.0.1:28332"
[[node.backup]]
rpc_url = "http://192.168.1.50:8332"
zmq_hashblock_addr = "tcp://192.168.1.50:28332" # "" = no ZMQ from this node (poll-only)
# rpc_cookie_path = "/mnt/node-b/.bitcoin/.cookie" # optional — see auth below
Authentication is resolved per backup, in this order:
rpc_cookie_path— a local second node with its own data dir.user:passembedded in the URL (http://user:pass@host:8332) — a remote node with its own credentials (this lives inconfig.toml, so mind the file permissions).- Nothing — the global
secrets.rpc_user/rpc_passare reused (handy when the backup is provisioned identically to the primary).
The
[node]block is read at startup only — restart the daemon after adding or changing a backup. This briefly interrupts mining; miners reconnect on their own.
The full field reference is in the Configuration Reference.
How failover behaves
- Sticky, primary-preferred. Calls run against the last working node. On a transport, auth, or warm-up error they hop to the next node within the same call — the job loop never stalls. While running on a backup, about once a minute one call probes the primary first and switches back the moment it answers.
submitblockgoes to every node in parallel. The block moment is the one instant a dead node can cost real money, so the solved block is broadcast to all nodes at once; the firstAcceptedwins.- ZMQ subscribes to every node that sets
zmq_hashblock_addr. Block events are deduplicated by hash, so whichever node reports first triggers the template refresh — you automatically get the lowest-latency node. - Degraded ≠ down. A pool happily running on a backup is degraded, not
down: the health snapshot carries a warning, and
rpc-downfires only when no node is reachable at all.
Verified live: primary kill → failover in under a second with the job stream uninterrupted; primary restart → automatic return within ~60 s.
Notifications
node-failover— the active node changed (failover to a backup, or return to the primary).rpc-down— no node reachable at all.rpc-recovered— RPC is answering again after anrpc-down.
All three go to sinks that enabled on_rpc_down.
See Notifications for sink setup.
Tips
- Put the backup on separate hardware and power — a backup on the same
machine only covers
bitcoindcrashes, not host failures. - A node without ZMQ still works as an RPC failover target (poll-only); set
zmq_hashblock_addr = ""to skip ZMQ from a node that doesn’t expose it. - Order matters only as preference: the primary is always tried first on the periodic probe; backups are tried in the order they appear.
See also
- Configuration Reference — full
[[node.backup]]fields - Notifications —
node-failover/rpc-downevents - Setup Health-Checks
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 these 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 | No Bitcoin Core node reachable at all (with [[node.backup]] a primary outage alone no longer fires this) | medium |
rpc-recovered | Health check first hits RPC=ok again after a down state | low |
health-alert | A setup/health check started failing (e.g. Bitcoin Core warning). Gated by on_health_alert, on by default | medium |
node-failover | The active Bitcoin Core node changed (failover to a backup, or return to the primary). Delivered to sinks that enabled on_rpc_down | medium |
test | Operator clicks the test button in the admin UI | low |
Each sink can toggle which events it cares about (all bool). ntfy, Slack
and Email support on_block_found, on_miner_disconnect, on_rpc_down
and on_health_alert; Telegram and Discord support on_block_found and
on_health_alert. Defaults everywhere: on_block_found = true and
on_health_alert = true, the rest off. Since v1.0.16 all sinks can also
be configured from the admin UI (tokens land write-only in
secrets.toml).
Retry: 3 attempts per sink — the first immediately, then after 10 s and after 30 s. If everything fails, it gets logged but the daemon keeps running — a notifier failure must never disrupt mining.
Per-wallet miner notifications
Everything above is the operator’s notifier: pool-wide sinks configured in
config.toml, firing on any block or any health event. A public
0%-fee per-worker pool, where every miner mines to their
own address, needs the opposite: each miner wants alerts about their own
address only — without the operator wiring anything up, and without an account.
That is what the owner panel on a wallet page provides. It is a separate,
self-service path with no config.toml involvement at all:
- A miner opens their public page at
/users/<address>. - They click verify ownership, sign the one-time challenge in their wallet’s Sign Message box (BIP-137 — no key revealed, no funds moved, no account), and submit the signature.
- Once verified, a Notifications field appears. They paste a personal target and save it — for this address only.
| Per-wallet event | Fires when | Target sees |
|---|---|---|
| block-found | this address finds a block | worker, height, reward, block hash (high priority) |
| personal-best | this address sets a new best-ever share difficulty across all its rigs (block shares are excluded — the block is the headline) | the new best-share difficulty and rig (per-address 30-minute cooldown) |
| top-100 | this address first enters the pool’s all-time top-100 best shares — a genuine entry only: the leaderboard was already full and the address held no prior spot (climbing higher does not re-fire) | the qualifying share difficulty and rig (per-address 60-minute cooldown) |
| rig-offline | one of this address’s rigs loses its Stratum connection (per-worker debounced, same as the operator miner-disconnect) | which rig went offline |
The target is a single http(s) URL — either an ntfy topic URL
or a generic webhook. The daemon POSTs an ntfy-compatible request
(Title / Priority / Tags headers + a text body), so an ntfy topic renders
as a proper push notification and a plain webhook receives the text body.
Because the target is miner-supplied (untrusted), every per-wallet POST goes
through the same SSRF guard as outbound webhooks — internal / link-local hosts
(127.0.0.1, 169.254.169.254, 10.x, …) are refused and redirects are not
followed, so a miner can’t turn the daemon into a proxy onto the operator’s LAN.
Delivery failures are logged and dropped; a miner’s dead endpoint never affects
mining or the operator’s own sinks.
This is not the payout engine. The owner panel is a free, non-custodial notification + privacy feature in the OSS core — signing a message proves an address is yours, nothing more. It is unrelated to any reward-sharing or custody product.
Taproot note: ownership signing works for single-key P2PKH / P2WPKH / P2SH-P2WPKH addresses. A Taproot (P2TR) miner can mine and gets a public page, but cannot open the owner panel — the legacy signed-message format has no Taproot variant.
Sharing notifications with friends
A solo+friends pool is more fun when everyone gets the 🎉 the moment a block is found — not just the operator. Every server-side sink above is a broadcast channel, so nothing new needs to be built: you just point the sink at something your whole group can see.
| Channel | How friends subscribe | Effort |
|---|---|---|
| ntfy | Install the ntfy app, subscribe to your topic name | ~2 min, no account |
| Telegram | Your bot posts into a group everyone is in | one-time setup |
| Discord | Webhook points at a channel on a shared server | one-time setup |
Their addresses go into the to = [...] list | trivial |
ntfy: the zero-friction option
ntfy is the easiest way to let a whole group subscribe — no accounts, no bots, no invites. The topic name is the subscription:
- You (the operator) configure
[notifier.ntfy]as shown below. - Each friend installs the ntfy app (Android / iOS / web — see
ntfy.sh) and subscribes to the exact same topic name,
e.g.
my-secret-pool-topic-abc123. - Done — every
block-found(and whatever else you enabled) lands on every subscribed phone simultaneously.
Two things to keep in mind:
- The topic name is the only secret. On public ntfy.sh, anyone who knows it can read your pool events and post their own messages to the topic. Pick a long random string, share it privately, rotate it if it leaks. Pool events carry no sensitive data (worker names, block height, health states) — the realistic worst case is a prankster posting fake “block found” messages.
- If your group outgrows the shared-secret model, self-hosted ntfy (or a paid ntfy.sh tier) adds real access control: reserved topics with read-only access for subscribers.
Telegram: group chat instead of DM
The chat_id doesn’t have to be your private chat — a group works exactly
the same way:
- Create a Telegram group with your friends.
- Add your bot to the group.
- Send any message in the group, then fetch
https://api.telegram.org/bot<TOKEN>/getUpdatesand look for the group’s"chat":{"id":-100…}— group ids are negative; copy the minus sign too. - Put that id into
chat_idand restart the daemon.
Everyone in the group now sees every notification, and the group doubles as the place to celebrate.
Discord and email
- Discord — webhooks post into one channel; pick a channel on a server your friends are on (instead of a private one) and you’re done.
- Email —
tois a list, every address gets its own copy:to = ["you@example.com", "friend-a@example.com", "friend-b@example.com"].
What about Web Push?
The PWA background push subscribe button lives on the admin notifications page, which requires the operator login — so treat Web Push as the operator’s personal channel and use the sinks above for the group.
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).
Outbound webhooks
For any target not built in as a dedicated sink (PagerDuty, Opsgenie, a custom
endpoint, a home-automation hook), the pool ships a generic outbound webhook
with HMAC signing — no vendor lock-in. Configure one or more in config.toml:
[[webhooks]]
url = "https://example.com/hook"
secret_env = "MY_HOOK_SECRET" # optional — HMAC-SHA256 over the request body
events = [] # empty = default low-frequency set
# enabled = true # set false to disable without deleting the block
- Payload: JSON
{ "event": "<type>", "ts": "<rfc3339>", "data": { … } }—tsis an RFC3339 string (e.g."2026-06-19T12:00:00+00:00"), not a unix number. - Headers:
X-WarpPool-Event: <type>and, whensecret_envis set,X-WarpPool-Signature: sha256=<hmac-hex>(GitHub-style, verify against the raw body). - Events: the default set is low-frequency —
block_found,health_snapshot,update_available,profile_switched. Opt into the high-frequencynew_job/shares_acceptedexplicitly viaeventsonly if you want the POST storm. - Delivery: 10 s timeout per request, 3 attempts (immediate, then after 1 s and 3 s). After that the event is logged and dropped — there is no unbounded buffer, so a down consumer cannot back the pool up. Loopback/LAN targets are allowed by design (operator-controlled, no SSRF block).
See also Extending → public API & webhooks.
Other Phase B options
- Matrix — not implemented; PR welcome, analogous to the Slack sink pattern.
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_wallets_total | gauge | Distinct wallet prefixes ever seen (lifetime) |
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 |
Aggregated pool gauges (always present)
Computed per scrape from the pool stats, the live network snapshot, and the most recent completed 5-minute share bucket.
| Metric | Type | Description |
|---|---|---|
warppool_active_wallets | gauge | Distinct wallet prefixes with an accepted share in the last 5 min |
warppool_best_share_difficulty | gauge | Highest share difficulty ever accepted (0 if none yet) |
warppool_pool_hashrate_hps | gauge | Pool hashrate (H/s) from the last completed 5-min bucket |
warppool_network_hashrate_hps | gauge | Estimated Bitcoin network hashrate (H/s, getmininginfo, last 120 blocks) |
warppool_pool_hashrate_hps uses the completed bucket on purpose: the
in-progress bucket holds only a fraction of its 5-minute window and would read
low. For a solo pool with a single small miner this value is noisy by nature
(few shares per bucket — Poisson scatter), so smooth it in PromQL rather than
trusting a single sample:
avg_over_time(warppool_pool_hashrate_hps[15m])
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_active_connections{protocol="v1_tls"} | gauge | Subset of v1 carried over TLS (plain v1 = v1 − v1_tls) |
warppool_bitcoin_rpc_latency_seconds | histogram | End-to-end RPC call duration (all retries included) |
Total active miners = sum(warppool_active_connections).
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 on the active profile’s interval — 30 s on Small down to 1 s on
Enterprise (see the profile table)
— 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, node-failover,
health-alert, 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])
Rate-limit / IP bans
Present when the rate-limiter is wired (the daemon does this automatically). Counts only — no per-IP labels (cardinality and operator privacy).
| Metric | Type | Description |
|---|---|---|
warppool_manual_bans | gauge | IPs on the manual (permanent) block-list |
warppool_auto_bans_active | gauge | Currently active automatic (temporary) bans |
# Alert hint: auto-banner suddenly very active (abuse wave or misconfig)
warppool_auto_bans_active > 20
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": "Pool Hashrate (H/s)",
"type": "timeseries",
"targets": [{
"expr": "avg_over_time(warppool_pool_hashrate_hps[15m])"
}]
},
{
"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. The full set of event types:
| Event | Fires when |
|---|---|
block_found | A block was submitted to Bitcoin Core. The first push has accepted: null; a second event carries the accept/reject verdict. |
new_job | A new template arrived (ZMQ, IPC push, or poll). High frequency. |
shares_accepted | Aggregated share counter, throttled to at most once a second. High frequency. |
profile_switched | The admin hot-switched the active profile. |
health_snapshot | Periodic Bitcoin Core health re-check (RPC, peers, IBD, pruned, ZMQ, warnings). |
update_available | The periodic auto-update check found a newer release. |
heartbeat | Server-side keepalive with the current subscriber count. |
It’s primarily intended for the UI banners; for monitoring, use /metrics —
Prometheus is more robust against scraping pauses. The same events can be
pushed to your own endpoint instead — see
[[webhooks]].
Note that the operator announcement banner is not an SSE event: the UI
picks it up from the banner field of /api/overview (see
Announcement banner).
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
Symptom: job refresh runs poll-only because no ZMQ watcher is active — the
[node] zmq_hashblock_addr in config.toml is empty or missing (it is not an
env var). If a configured channel later drops, the log shows:
zmq channel closed — falling back to polling 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.
External miner suddenly can’t connect (auto-banned)
A public IP that repeatedly trips the connect rate limit is auto-banned and
dropped before the handshake (default: 10 strikes / 60 s → escalating 5 min …
1 h). Loopback and private LAN are never auto-banned. Check the ban state
via /metrics (warppool_auto_bans_active, warppool_manual_bans) or the
Admin → Profile panel. Clear a manual ban with POST /api/admin/ratelimit/unban;
relax auto-ban via [ratelimit].autoban_* (or autoban_enabled = false).
Dashboard shows “API unreachable → HTTP 429”
The HTTP API is per-IP rate-limited (api_rpm, profile-driven; over the limit
→ HTTP 429 + Retry-After). Loopback and private LAN are exempt (the LAN
exemption was widened in v1.4.1 — before that, reaching the dashboard over the
box’s LAN IP with several tabs + polling could trip it). If you still see it
from a genuinely remote IP, switch to a larger profile (bigger api_rpm) or put
a reverse proxy with its own limits in front.
Miner is on the wrong protocol port
Each protocol has its own endpoint: Stratum V1 plain on 3333,
V1-over-TLS on 3334, Stratum V2 on 34254 (defaults; the connect
modal in the UI shows the exact host:port for all three). A miner in
SV2 mode pointed at 3333 fails its NOISE handshake against the
line-based V1 listener; a V1 miner on 34254 dies in the encrypted
framing. Since v1.0.22 both listeners detect this mismatch and log a
warning naming the right port — grep -i "point it at" in the pool log
finds it. (Real-world example: #60,
a Bitaxe in SV2 mode configured with the V1 port.)
TLS: handshake fails with BadCertificate
Symptom: the miner works on port 3333 but not on the TLS port; the daemon log shows, once per reconnect attempt:
WARN tls handshake failed peer=192.168.x.y:nnnnn error=received fatal alert: BadCertificate
The alert comes from the miner: its firmware verifies the server certificate and rejects the pool’s self-signed one. What works depends entirely on what the firmware offers (ESP-Miner family naming):
| Firmware TLS mode | Self-signed pool cert | What to do |
|---|---|---|
| No verification | ✓ works | nothing — connect to :3334 |
| TLS (Custom CA certificate) | ✓ works | download the cert from the connect modal (or GET /api/tls-cert), paste it as custom CA, and use a hostname from the certificate (umbrel.local, dvb-warppool.local) instead of a raw IP — esp-tls also checks the common name |
| TLS (System/Bundled CA) only — e.g. BitForge Nano firmware v1.5 | ✗ can never work | see below |
A bundled-CA-only firmware insists on a certificate signed by a public CA — that is the whole point of the check, so there is no self-signed workaround. Realistic options:
- Stay on plain
:3333for that miner. In a home LAN this is a perfectly reasonable trade-off (TLS on Stratum mainly guards against on-path tampering; the share data itself is not secret). - Serve a CA-signed certificate: if you own a domain, issue e.g. a
Let’s Encrypt certificate for
pool.example.com(DNS-01 challenge — the host doesn’t need to be publicly reachable), point the DNS record at the pool’s LAN IP, set[stratum] tls_cert_path/tls_key_pathto the issued files and connect the miner topool.example.com:3334. Renewal (90 days for LE) plus a daemon restart is your responsibility. - Ask the vendor for a firmware update — ESP-Miner mainline already ships the “TLS (Custom CA certificate)” mode, so forks usually pick it up over time.
A plaintext miner pointed at the TLS port produces a different error
(tls handshake failed ... received corrupt message-style) — that is a
forgotten SSL toggle, not a certificate problem.
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 release asset 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 forge
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.
Announcement banner doesn’t show up
The marquee announcement (Admin → Banner, since v1.21.0) is stored as two
pool_settings rows — text plus an RFC3339 expiry — and served in the banner
field of GET /api/overview. It is suppressed when the text is empty, the
expiry is missing/unparseable, or the expiry is already in the past — in that
case the field is omitted from the response entirely, not sent as an empty
string. So:
curl -s http://pool.local:18334/api/overview | grep -o '"banner":[^}]*}'
If that prints nothing, the announcement has expired (or was cleared) —
re-post it via Admin → Banner or
POST /api/admin/banner with {"text": "...", "duration_secs": 3600}.
Constraints the API enforces: at most 200 characters, no control characters,
and duration_secs between 60 (1 minute) and 7776000 (90 days). Ending it
early is DELETE /api/admin/banner; both writes land in the audit log
(banner.set / banner.clear).
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/warppool.db /var/lib/dvb-warppool/warppool.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, executed by
the self-hosted Forgejo runner) builds the Linux + Docker artifacts in
parallel on every v* tag and signs them with the project Cosign key
(cosign.pub in the repo root).
macOS and Windows are not built by CI — there are no macOS/Windows runners on the forge. The macOS
.dmg+ tarballs are produced per release on a Mac withpackaging/macos/build-macos-release.shand attached manually (which also meansSHA256SUMSgets regenerated over the full asset set and re-signed — see §8 ofdocs/ci-forgejo-porting.mdin the repository; that note is not part of this handbook). The Windows.msiis paused.
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 | ✅ | git.warppool.org/dvb-projekt/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, built manually | No macOS runner — built per release via packaging/macos/build-macos-release.sh; Phase B: Notarization (Dev-ID) |
.msi | x64 | ⏸ paused | Not built since the forge move (no Windows runner) — WiX sources maintained |
| Tarball | Linux + macOS | ✅ | Manual installs |
| Umbrel | amd64 + arm64 | ✅ | packaging/umbrel/dvb-warppool/ |
Linux .deb (Debian / Ubuntu / Mint)
# download from the release page on git.warppool.org:
wget https://git.warppool.org/dvb-projekt/dvb-WarpPool/releases/download/v1.24.0/dvb-warppool_1.24.0-1_amd64.deb
sudo apt install ./dvb-warppool_1.24.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-1.24.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-1.24.0-x86_64.AppImage
./dvb-WarpPool-1.24.0-x86_64.AppImage
Also available as an aarch64 AppImage for ARM boards (Raspberry Pi 5, Pine64, Rock-Pi):
chmod +x dvb-WarpPool-1.24.0-aarch64.AppImage
./dvb-WarpPool-1.24.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 (Large) 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://git.warppool.org/dvb-projekt/dvb-WarpPool/releases/download/v1.24.0/dvb-warppool_1.24.0-1_arm64.deb
sudo apt install ./dvb-warppool_1.24.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 \
git.warppool.org/dvb-projekt/dvb-warppool:1.24.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 (Large) as the highest recommended tier
for that hardware — a recommendation, not an enforced cap — 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 (Small) 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
⏸ Releases currently ship no
.msi. There has been no Windows build runner since the move to the project’s own forge, so the release pipeline skips this job (.github/workflows/release.yml, job 6). The WiX packaging (packaging/windows/main.wxs) is still maintained and the artifact returns as soon as a Windows runner exists. Until then, build it yourself withcargo wixon a Windows machine, or run the pool via Docker or the Linux packages. The steps below apply to an.msiyou built yourself.
⚠ 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 \
git.warppool.org/dvb-projekt/dvb-warppool:1.24.0 \
--config /config/config.toml --data-dir /data
First run: the admin account needs /config writable — once
The first visit to /admin asks you to create an admin account, and the
daemon writes those credentials to secrets.toml next to your
config.toml. With /config mounted read-only (as above) that write fails
and the UI reports that the secrets file is not writable.
The write is atomic (temp file + rename), so it needs the directory
writable — bind-mounting only secrets.toml read-write is not enough. Two
ways round it:
a) Open a window for the first run, then close it. Drop :ro (and make
the directory writable by the container’s uid if you run with user:), create
the account, then restore :ro and recreate the container. Only the one-time
bootstrap needs this: everything the admin UI stores afterwards — donation
addresses, tab toggles, suppressed warnings — lives in the database under
--data-dir, so read-only config is fine from then on.
b) Pre-provision secrets.toml. If it already carries
admin_username, admin_password_hash and jwt_secret, no bootstrap is
needed and /config can stay read-only from the start.
If you run the container as a non-root
user:— note that the daemon only readssecrets.toml, but it must be able to read it. Leave the file owned by that uid with mode600; a root-owned600file locks the daemon out.
Multi-arch (amd64 + arm64) is selected automatically — no --platform needed.
Signature verification (Cosign)
Every release includes SHA256SUMS plus a Cosign signature bundle
(SHA256SUMS.bundle), verified against the project cosign key (cosign.pub
in the repo root). Cosign 3.x writes the bundle format rather than a bare
.sig, so pass --bundle:
cosign verify-blob \
--key cosign.pub \
--bundle SHA256SUMS.bundle \
SHA256SUMS
# must print: Verified OK
# then against the individual files:
sha256sum --check SHA256SUMS
GitHub-era releases (<= v1.15.0) were keyless-OIDC-signed and shipped a
SHA256SUMS.sig — those artifacts are gone with the account, see the repo
history for the steps they used.
Build provenance
Releases ≤ v1.15.0 (GitHub era) shipped SLSA-3 provenance via
slsa-github-generator (*.intoto.jsonl in the release assets). That generator
is GitHub-OIDC-bound and does not run on the self-hosted forge, so newer releases
rely on the project Cosign signature (above) plus reproducible builds
(rebuild byte-for-byte and compare SHA256SUMS, see
reproducible builds) for supply-chain assurance instead.
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. Since the.dmgis currently built manually on a Mac (no macOS runner), notarization would be a local step there; if a macOS runner is ever added,APPLE_ID,APPLE_TEAM_IDandAPPLE_NOTARY_PASSWORDwould go into the Forgejo Actions 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}/.
Reverse Proxy (nginx / Caddy)
The daemon serves its HTTP API + UI on status_listen (default :18334) as
plain HTTP. For a public or friends pool you usually want it behind a reverse
proxy that terminates TLS (HTTPS) on port 443, so miners and the dashboard
reach it over an encrypted, named endpoint (https://pool.example.com).
You have two ways to get HTTPS — pick one:
- Built-in TLS — set
status_tls_listen(and provide a cert) and the daemon serves HTTPS directly. Simplest; no extra software. See Configuration Reference. - Reverse proxy (this page) — an established web front (nginx / Caddy) does TLS, HTTP/2, automatic certificate renewal, access logs and its own rate-limit, forwarding to the daemon over loopback. This is the recommended setup once the pool is internet-facing.
Stratum is not HTTP. Only the API/UI (
:18334) goes through an HTTP reverse proxy. The Stratum endpoints — plain:3333, TLS:3334, and Stratum V2 on its ownsv2_listenport (the reference deployment uses:3336, NOISE NX handshake) — are raw TCP protocols. Expose those ports directly (or through an L4/TCP load balancer such as nginxstream {}or HAProxy TCP mode), never through an HTTPserver {}/reverse_proxyblock.
Two settings that matter behind a proxy
When the daemon runs behind a proxy, set both of these in config.toml under
[server]:
[server]
# The proxy now sees the real client; the daemon only sees the proxy's loopback
# address. Setting this true makes the daemon trust the proxy's X-Forwarded-For
# header for the real client IP — used by the audit log and the per-IP rate-limit
# and ban list. See the warning below.
trust_proxy_headers = true
# The login cookie is only sent over HTTPS. Set true whenever the UI is reached
# via https:// (i.e. always, behind a TLS-terminating proxy).
cookie_secure = true
⚠️ Only set
trust_proxy_headers = truebehind a proxy you control. If the daemon is reachable directly from the internet with this on, any client can forgeX-Forwarded-Forand spoof its IP into your audit log and past your IP bans. Bindstatus_listento loopback (127.0.0.1:18334) so only the proxy can reach it. See Security.
Public wallet pages (/users/<address>)
A per-worker 0% pool exposes a login-free page per miner
at /users/<address>, with ckpool-compatible JSON at /api/users/<address> for
firmware and ckstats. Both are ordinary HTTP on the same status port as the
dashboard, so the catch-all location / (nginx) / reverse_proxy (Caddy) below
already forwards them — no per-route config needed (unlike /api/events,
they are plain request/response, no buffering tweaks). Just confirm they answer
through the proxy: curl https://pool.example.com/api/users/<some-address>
should return the ckpool-style JSON, and https://pool.example.com/users/<address>
should load the wallet page.
Server-Sent Events (/api/events)
The dashboard’s live updates (block-found, new-job, …) use a long-lived
Server-Sent-Events stream at GET /api/events. A proxy that buffers responses
or times the connection out will break live push. Both examples below disable
response buffering and raise the read timeout for that path.
Caddy
Caddy gets you automatic HTTPS (Let’s Encrypt) with almost no config:
pool.example.com {
reverse_proxy 127.0.0.1:18334 {
# SSE: stream the response, don't buffer it.
flush_interval -1
}
}
That’s the whole file. Caddy fetches and renews the certificate, terminates TLS,
forwards X-Forwarded-For / X-Forwarded-Proto automatically, and flush_interval -1 keeps /api/events flowing.
nginx
server {
listen 443 ssl http2;
server_name pool.example.com;
ssl_certificate /etc/letsencrypt/live/pool.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pool.example.com/privkey.pem;
# Dashboard, API, and the public wallet pages (/users/<address> +
# ckpool-compatible /api/users/<address> JSON) — all plain HTTP, no
# per-route tuning needed.
location / {
proxy_pass http://127.0.0.1:18334;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Server-Sent-Events stream: no buffering, long-lived connection.
location /api/events {
proxy_pass http://127.0.0.1:18334;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 1h;
}
}
# Redirect plain HTTP → HTTPS
server {
listen 80;
server_name pool.example.com;
return 301 https://$host$request_uri;
}
Use certbot (certbot --nginx -d pool.example.com) for the certificate.
Checklist
-
status_listen = "127.0.0.1:18334"— daemon only reachable via the proxy. -
trust_proxy_headers = trueandcookie_secure = truein[server]. -
/api/eventsis proxied without buffering (Caddyflush_interval -1/ nginxproxy_buffering off). - Public wallet pages reachable through the proxy:
/users/<address>loads and/api/users/<address>returns JSON (per-worker pools only; served by the catch-all block, no special config). - Stratum ports (
:3333plain,:3334TLS,sv2_listene.g.:3336) exposed directly, not via the HTTP proxy. - After changing
[server], restart the daemon.
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; escalating auto-ban of abusive public IPs (loopback/LAN exempt) + operator manual ban list (applies to any IP incl. LAN; loopback stays exempt so the operator can’t lock out the local UI — a deliberate ban overrides only the auto-ban LAN exemption); optional CIDR IP-gate (ban_cidrs deny / allow_only + allow_cidrs whitelist) and per-IP concurrent-connection cap ([stratum].max_connections_per_ip) | 3, 32, 33 |
| Single-IP connection exhaustion | Per-IP concurrent-connection cap ([stratum].max_connections_per_ip, default 0 = unlimited) — one host can’t monopolise the global connection pool. Loopback exempt | 33 |
| Unwanted / hostile network ranges | CIDR IP-gate: [ratelimit].ban_cidrs denies ranges outright; allow_only = true + allow_cidrs flips the listener to a whitelist. Loopback always passes (no local lock-out). Enforced even when the token-bucket rate-limit is disabled | 33 |
| Persistently rejecting worker (broken / hostile) | Reject-ratio auto-ban ([ratelimit].reject_ban_*): a worker whose rejected/total share ratio exceeds the threshold after a minimum sample is banned for the escalating auto-ban duration. Public-IP-only (loopback/LAN exempt); off by default. Enforced even when the rate-limit is disabled | 33 |
| Unauthorized payout address mining | Optional payout-address allowlist ([mining].address_allowlist) — enforced on Stratum V1 mining.authorize and on Stratum V2 channel-open (Standard + Extended); empty = open (default) | 33 |
| 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.2 + 1.3, no TLS 1.0/1.1) | 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 (project cosign key; keyless OIDC only ≤ v1.15.0), 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 (worker, job_id, 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. Single-use recovery codes (hashed at rest, shown once) are the lockout escape hatch | 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
-> issue single-use recovery codes (hashed at rest, shown once)
-> audit: 2fa.enable
2FA Recovery Codes
Enabling 2FA also issues a set of single-use recovery codes. They are shown exactly once at enable time and only their hashes are stored — the same one-shot rule as API tokens. Write them down before leaving that screen.
At login, the totp_code field also accepts a recovery code: a plausible-
looking code is checked against the unused set, and on match it is consumed
(one use, never again) and the login succeeds. This is the way back in when
the authenticator device is lost — without it, a lost phone means losing
admin access to the pool.
GET /api/auth/2fa/status reports recovery_remaining, so the UI can warn
as the pile runs low. POST /api/auth/2fa/recovery/regenerate rotates the
set (invalidating all outstanding codes); this is also the way to refresh
codes while 2FA stays active, since enable refuses to run twice.
Recovery-code logins are audited separately as login.recovery, including
how many codes remain — an unexpected entry there is a strong signal of
account compromise.
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 with the crate’s default params (m=19 MiB, t=2, p=1) — 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_authority_priv_key_hex | secrets.toml | chmod 0600 | With the Sv2 authority key someone can impersonate the pool toward V2 miners. Auto-generated on first start (or via 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 | secrets.toml (vapid_private_key) | chmod 0600 | 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/* |
login.recovery | /api/auth/login (a single-use 2FA recovery code was consumed; details carry the remaining count) |
token.create / token.revoke | /api/admin/tokens/* |
profile.switch | /api/admin/profile |
miner.add / miner.delete | /api/admin/miners/* |
backup.export / backup.restore | /api/admin/backup / /api/admin/restore |
update.check / update.applied / update.failed | /api/admin/update* |
notifier.test / notifier.config | /api/admin/notifier/* |
ratelimit.ban / ratelimit.unban | /api/admin/ratelimit/{ban,unban} |
login.throttled | /api/auth/login (per-IP throttle hit) |
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 is automatic. The daemon’s retention loop calls
evict_audit_older_than([retention].audit_log_secs) — default 90 days —
at most once per hour, so the DELETE does not run on every 60 s tick. Set
[retention].audit_log_secs to change the window. (Earlier releases had no
daemon-side eviction and required a cron job; that is no longer the case.)
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
(TLS 1.2 + 1.3, no TLS 1.0/1.1).
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 (sv2_listen, :3336 in the reference deployment)
NOISE-NX handshake (Stratum V2 2024 spec: secp256k1 + EllSwift + Schnorr) via
the noise_sv2 crate, pure Rust. Static authority key in
secrets.sv2_authority_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_authority_pubkey_b58(Base58, 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.24.0
# fetches the release tarball, extracts the daemon binary, 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 the project cosign key (cosign.pub in the repo root):
cosign verify-blob --key cosign.pub --bundle. (GitHub-era releases ≤ v1.15.0
were keyless via GitHub Actions OIDC + Fulcio; that path is GitHub-bound and
gone since the move to the self-hosted forge.) For anyone who wants a second
path: 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.
Auto-ban + manual ban list ([ratelimit])
A public IP that repeatedly trips the connect limit (default: 10 strikes /
60 s) is fully banned — rejected before the TLS/handshake — for an escalating
duration (base 5 min, doubling, capped at 1 h). Loopback and private LAN
(10/8, 172.16/12, 192.168/16, link-local) are never auto-banned, so local
miners that reconnect in bursts can’t lock themselves out. Tunable via
[ratelimit].autoban_* (off via autoban_enabled = false). On top, an
operator-curated [ratelimit].manual_bans list (admin API
/api/admin/ratelimit/{bans,ban,unban} + UI panel) blocks specific IPs
permanently — these apply to any IP including LAN, but loopback stays exempt
(so the operator can’t lock out the local UI); a deliberate ban overrides only
the auto-ban’s LAN exemption.
Reject-ratio auto-ban ([ratelimit].reject_ban_*)
Off by default. When enabled, a worker whose rejected-share ratio
(rejected / (accepted + rejected)) is at or above reject_ban_ratio once at
least reject_ban_min_samples shares have been observed is banned for the same
escalating auto-ban duration as the connect auto-ban. Public-IP-only — loopback
and private LAN are never banned this way, so a noisy local miner can’t lock
itself out. Because a ban is an access-control decision, an active ban is
enforced on the next connect even if the token-bucket rate-limit
(enabled = false) is turned off, as long as the ban mechanism that produced it
(autoban_enabled or reject_ban_enabled) is set.
IP-gate — CIDR deny-list + allow-only whitelist ([ratelimit])
Off by default. ban_cidrs is a deny-list of IPv4/IPv6 CIDR ranges (a bare IP is
treated as /32 / /128) — any connect from a listed range is refused before
TLS/handshake. Setting allow_only = true flips the listener into a whitelist:
only IPs matching allow_cidrs may connect. Loopback always passes regardless of
the gate, so the operator can never lock out the local UI / local miners.
Malformed CIDR entries are skipped (logged once at load). Like the bans, the gate
is an access-control decision and applies even when the rate-limit is disabled.
Per-IP connection cap ([stratum].max_connections_per_ip)
Default 0 (unlimited). When set, a single peer IP may hold at most that many
concurrent Stratum connections; further connects from the same IP are dropped at
accept time. This bounds single-host connection exhaustion independently of the
profile-wide connection cap. Loopback is exempt.
Payout-address allowlist ([mining].address_allowlist)
Empty by default (open — any Bitcoin address may mine). When populated, only the
listed payout addresses may mine: the address is everything before the first .
in the worker name. Enforced identically on Stratum V1 (mining.authorize is
rejected) and Stratum V2 (the channel-open returns an error for Standard and
Extended channels). Useful for a private “pool for friends” deployment.
Public-exposure privacy switches ([privacy])
Two booleans control what an anonymous visitor may read. Both default to
true, i.e. the historical open behaviour — a solo or friends pool keeps
working unchanged after an upgrade. A public deployment should review
them explicitly, because the defaults publish more than a public pool
usually wants:
| Key | Default | false means |
|---|---|---|
wallet_directory_public | true | The public wallet directory (/api/wallets) becomes operator-only |
leaderboard_addresses_public | true | Payout addresses in the best-shares leaderboard are masked for anonymous visitors |
[privacy]
wallet_directory_public = false
leaderboard_addresses_public = false
The authenticated operator always sees the unmasked data — these switches only affect the anonymous view. They complement the tab-visibility gating added in v1.19.1 (see the residual-risk table at the end of this page for what is still open on the unauthenticated read surface).
HTTP-API rate limit (api_rpm)
Separately from Stratum, the whole HTTP API is per-IP token-bucket limited
(profile-driven api_rpm); over the limit returns HTTP 429 + Retry-After.
Loopback and private LAN are exempt so the operator’s own UI is never throttled;
the limit targets public remote clients only.
Connection Cap
Profile-dependent (klein: 16, mittel: 64, gross: 256, enterprise: 4096).
Over-limit connects are dropped at accept time; semaphore-based, no
per-connection allocation beforehand. The profile is hot-switchable, so this
is a tuning knob rather than a fixed ceiling.
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 plain, unencrypted
SQLite bytes. Restore via POST /api/admin/restore. There is no built-in
encryption — the export contains 2FA secrets and API-token hashes (see
below), so encrypt it yourself (age, gpg, an encrypted volume) before it
leaves the host.
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, an issue on git.warppool.org 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 | Largely closed in v1.19.1: /api/miners and /api/hardware now honour the operator’s tab-visibility settings — an anonymous visitor gets an empty list / 403 unless the operator publishes the tab, while the authenticated operator still sees everything; /api/wallets and the leaderboard follow [privacy].wallet_directory_public / leaderboard_addresses_public (both default to the historical open behaviour, so a public deployment must set them). Still open: /metrics and /api/energy are unauthenticated and expose aggregate device/power data — bind /metrics to localhost or gate it in the reverse proxy for a public pool |
| 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 signatures say “the build came from the project” (GitHub-era releases ≤ v1.15.0 additionally carried SLSA workflow provenance). 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
repro.yml and scripts/verify-reproducible.sh both derive
SOURCE_DATE_EPOCH from the commit timestamp, so a verification build uses
the same convention as the reference build. Archive formats (deb/rpm,
tarballs) 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
repro.yml
runs on manual dispatch and as a fixed step of the release routine:
build-a ─┐
├─► verify-determinism (sha256 comparison)
build-b ─┘
Two matrix runs build the same dvb-warppool-daemon binary from the same
commit. On drift the workflow fails and prints both sha256s.
The push-to-
mainauto-trigger is currently off: the double release build is too heavy for the 2-vCPU Forgejo runner. It returns if a bigger runner is added.
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 published release asset
- compares both sha256s and exits 0 (match) or 1 (drift)
Note (post-Forgejo migration): the script still resolves the tag and the asset URL against the old GitHub repository, which is no longer reachable. Until it is ported to the
git.warppool.orgrelease API, pass the tag explicitly and compare against a manually downloaded asset from the Forgejo releases page.
# 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. Since the move to Forgejo there are no macOS or Windows CI runners either: the macOS assets are built and signed manually per release, and the Windows.msiis paused (the WiX sources stay maintained). - 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 shipped across Phases 8c–8g: update-check + verified download as CLI subcommands (8c Foundation), then atomic swap + health check + rollback wired into the daemon itself (8d Wiring onward). All of it is in the current release; the sections below cover the CLI entry points and the daemon flow.
CLI update commands (Phase 8c)
Two CLI commands, manually triggered by the operator:
dvb-warppool-cli check-update
Fetches the latest release from the project forge (Forgejo) and compares it against the
installed version (CARGO_PKG_VERSION from the CLI binary).
$ dvb-warppool-cli check-update
Installed: 1.4.0
Latest: v1.4.1 (Release v1.4.1)
Update: 1.4.0 → 1.4.1 (available)
Release-Assets:
· dvb-warppool-v1.4.1-x86_64-unknown-linux-gnu.tar.gz (15728640 bytes)
· dvb-warppool-v1.4.1-aarch64-unknown-linux-gnu.tar.gz (14680064 bytes)
· dvb-warppool_1.4.1-1_amd64.deb (9437184 bytes)
· SHA256SUMS (1280 bytes)
· SHA256SUMS.bundle (2048 bytes)
Optional --json for scripting. --repo owner/name for forks or tests.
dvb-warppool-cli download-update
Downloads a release asset with sha256 verification. The Linux/macOS
assets are tarballs (dvb-warppool-<tag>-<triple>.tar.gz); the CLI
verifies the tarball’s sha256 and then extracts the dvb-warppool-daemon
binary to --to. It performs no atomic swap and no restart —
that’s the operator’s job, so the workflow stays auditable.
# Host-matching asset, auto-detected via the Rust target-triple
# (x86_64-unknown-linux-gnu / aarch64-unknown-linux-gnu / *-apple-darwin)
$ dvb-warppool-cli download-update \
--to /tmp/dvb-warppool-daemon \
--sha256 abcd1234... # tarball-sha aus SHA256SUMS
Download: dvb-warppool-v1.4.1-x86_64-unknown-linux-gnu.tar.gz (15728640 bytes)
✓ 15728640 bytes geladen, sha256=abcd1234...
✓ dvb-warppool-daemon (13007200 bytes) aus dem Tarball extrahiert → /tmp/dvb-warppool-daemon
→ Installieren (Operator-Schritt — kein Auto-Swap):
sudo install -m 755 /tmp/dvb-warppool-daemon /usr/local/bin/dvb-warppool-daemon
sudo systemctl restart dvb-warppool
The sha256 you pass is the tarball’s hash — the SHA256SUMS lines
cover the published assets (the .tar.gz), not the binary inside. On
mismatch the downloaded tarball is deleted — no “tainted download” left
lying around. --asset <substring> overrides the host auto-detection. --tag vX.Y.Z
downloads a specific release instead of latest — to pin a known-good
version or downgrade off a bad one (pass the tag with its v prefix; it hits
releases/tags/<tag> instead of releases/latest).
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://git.warppool.org/dvb-projekt/dvb-WarpPool/releases/download/$TAG/SHA256SUMS
curl -sSfLO https://git.warppool.org/dvb-projekt/dvb-WarpPool/releases/download/$TAG/SHA256SUMS.bundle
curl -sSfLO https://git.warppool.org/dvb-projekt/dvb-WarpPool/raw/branch/main/cosign.pub
# Project-key signing since the forge migration: the signature ships as a
# self-contained cosign 3.x *bundle*, so verify with --bundle, not --signature.
# GitHub-era releases (<= v1.15.0) were keyless-signed with a detached
# SHA256SUMS.sig — those assets are gone with the account.
cosign verify-blob \
--key cosign.pub \
--bundle SHA256SUMS.bundle SHA256SUMS
# extract the expected sha256 for the host tarball
TRIPLE="x86_64-unknown-linux-gnu" # or aarch64-unknown-linux-gnu / *-apple-darwin
ASSET="dvb-warppool-${TAG}-${TRIPLE}.tar.gz"
EXPECTED=$(awk -v a="$ASSET" '$2 == a {print $1}' SHA256SUMS)
# download + verify the tarball, then extract the daemon binary to --to
dvb-warppool-cli download-update --asset "$TRIPLE" \
--to /tmp/dvb-warppool-daemon --sha256 "$EXPECTED"
# manual swap (the daemon's POST /api/admin/update automates this)
sudo install -m 755 /tmp/dvb-warppool-daemon /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— forge release API (GitHub-compatible JSON schema) 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": "1.4.0",
"latest": "v1.4.1",
"name": "Release v1.4.1",
"prerelease": false,
"newer": "1.4.1",
"assets": [
{"name": "dvb-warppool-v1.4.1-x86_64-unknown-linux-gnu.tar.gz", "size": 15728640, "url": "..."},
{"name": "SHA256SUMS", "size": 1280, "url": "..."}
]
}
Audit: update.check with the resolved latest tag as target.
POST /api/admin/update
Downloads the chosen asset, verifies sha256, and — for the Linux/macOS
.tar.gz assets — extracts the dvb-warppool-daemon binary before the
atomic rename into target_path. asset is an optional substring match;
omit it and the daemon picks the asset matching its own host
target-triple. Native installers (.msi/.dmg/.deb/.rpm/.AppImage)
are rejected here — use the platform installer. 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": "x86_64-unknown-linux-gnu",
"sha256": "abcd1234...",
"target_path": "/usr/local/bin/dvb-warppool-daemon",
"backup_path": "/var/lib/dvb-warppool/backup/daemon.v1.4.0"
}' \
http://localhost:18334/api/admin/update
{
"tag": "v1.4.1",
"asset": "dvb-warppool-v1.4.1-x86_64-unknown-linux-gnu.tar.gz",
"bytes": 15728640,
"sha256": "abcd1234...",
"target_path": "/usr/local/bin/dvb-warppool-daemon",
"backup_path": "/var/lib/dvb-warppool/backup/daemon.v1.4.0",
"installed_from_archive": true,
"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 the forge 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. With the default 24 h
interval a pool makes fewer than 2 release-API requests a day, so no rate limit
on the forge side is anywhere near being a concern.
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": "x86_64-unknown-linux-gnu",
"sha256": "...",
"target_path": "/usr/bin/dvb-warppool-daemon",
"backup_path": "/var/lib/dvb-warppool/backup/daemon.v1.4.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. GitHub-era releases (≤ v1.15.0) used sigstore’s keyless OIDC flow;
since the move to the self-hosted forge, verification uses the
project cosign key (cosign.pub in the repo root).
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": "x86_64-unknown-linux-gnu",
"sha256": "abcd1234...",
"target_path": "/usr/bin/dvb-warppool-daemon",
"backup_path": "/var/lib/dvb-warppool/backup/daemon.v1.4.0",
"cosign_verify": true,
"cosign_args": [
"--bundle=/etc/dvb-warppool/SHA256SUMS.bundle",
"--key=/etc/dvb-warppool/cosign.pub"
]
}' \
http://localhost:18334/api/admin/update
Internally (after the asset download) the server fetches the release’s
signed SHA256SUMS asset and runs cosign verify-blob on that file
— the release signs only SHA256SUMS, not each individual asset, so
SHA256SUMS is the blob the signature covers. cosign verify-blob
checks the signature against its last (blob) argument, so that argument
must be SHA256SUMS, never the asset. Once the signature checks out the
server binds the asset to it: the downloaded asset’s sha256 (the
sha256 you passed) must appear as that asset’s line in the now
signature-verified SHA256SUMS, otherwise the update is refused (403):
# the blob (last arg) is the SHA256SUMS file the release signed —
# NOT the asset; per-asset integrity comes from its line inside it.
$WARPPOOL_COSIGN_BIN verify-blob \
--bundle=<path to SHA256SUMS.bundle> \
--key=<path to cosign.pub> \
<downloaded SHA256SUMS>
Note that cosign_args are passed through verbatim, so the bundle (or, for an
archived GitHub-era release, a detached --signature) has to be reachable from
the daemon — a local path or a URL cosign can fetch.
If cosign exits ≠ 0 → the downloaded asset (and the SHA256SUMS temp)
are deleted, response 403, audit update.failed with
cosign verify-blob failed: exit N. The same 403 fires if the asset’s
sha256 is not listed in — or mismatches — the verified SHA256SUMS.
Security model
| Layer | What is checked |
|---|---|
sha256 | File integrity against the hash supplied by the operator |
cosign verify-blob | Signature authenticity against the project cosign key (cosign.pub) |
cosign_args | Fully operator-controlled — the server only appends the downloaded SHA256SUMS file as the last (blob) 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 |
Platform availability
Since the move to the self-hosted forge, CI runs on the project’s own runners — and there is currently neither a macOS nor a Windows runner. That changes what a release contains:
| Platform | Asset | Built by |
|---|---|---|
| Linux x86_64 / aarch64 | .tar.gz, .deb, .rpm, .AppImage | CI, every release |
| Docker (amd64 + arm64) | image on git.warppool.org | CI, every release |
| macOS Intel / Apple Silicon | .tar.gz, .dmg | built manually on a Mac and attached to the release afterwards |
| Windows x86_64 | .msi | paused — no Windows runner |
Consequences for auto-update:
- macOS assets can lag the release. They are attached shortly after publish,
not at publish time, so a macOS host that checks for an update within that
window sees the new tag but not yet its asset. The
binariesjob deliberately does not depend on the macOS build, so a Mac being offline never blocks a release. Because the manual step re-signs the checksums,SHA256SUMSandSHA256SUMS.bundleare replaced when the macOS assets land — re-fetch both if you grabbed them before. - Windows has no asset at all right now. The CLI’s
x86_64.msihost mapping and the WiX packaging sources (packaging/windows/,cargo-wix) are kept maintained, and the.msireturns as soon as a Windows runner is registered. Until then, Windows users build from source. - macOS GUI installs go through the
.dmg1-click flow (POST /api/admin/update/install-macos-dmg) once that asset is present.
Other limitations today
- 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
Add-ons & Sidecars
dvb-WarpPool is designed to be extended from the outside — a clean, stable contract that lets you (or a commercial vendor) build extra functionality without forking the core.
The sidecar model (and the AGPL boundary)
dvb-WarpPool is licensed under AGPL-3.0-or-later (see LICENSE). The single most important rule for an add-on:
An add-on must be a separate process (a “sidecar”) that talks to the pool over its HTTP API / SSE — never an in-process Rust crate linked into the daemon.
Why this matters:
- A crate compiled into the AGPL daemon becomes a derived work → it is covered by the AGPL and its source must be offered to users.
- A separate process that only consumes the pool’s public API/SSE is an independent work. You can license it however you like — including keeping a proprietary commercial add-on closed-source. (For commercial use of the core itself, see COMMERCIAL-LICENSE.md.)
This is not a theoretical pattern — two sidecars already ship with the project:
- the Stratum V1↔V2 translator (
dvb-warppool-translator), and - the Solar bridge (a small Python service the pool polls over HTTP; see
Configuration Reference →
[solar],kind = "generic_http").
Dock points (the extension contract)
A sidecar has four ways to integrate:
- Read API —
GET /api/overview,/api/workers,/api/blocks,/api/hashrate,/api/best-shares,/api/version. These are public (no auth), return JSON, and are the stable surface (see below). Caveat: the[privacy]block can redact parts of the payload for unauthenticated callers —wallet_directory_public = falsemasks payout addresses in/api/workers,leaderboard_addresses_public = falsemasks them in the/api/hashrateleaderboard. The routes stay reachable; an operator token (see below) always sees the unmasked data. If your sidecar needs full addresses, give it a read token rather than relying on the defaults. - Live events (pull) —
GET /api/eventsis a Server-Sent-Events stream. Event types:block_found,new_job,shares_accepted,health_snapshot,profile_switched,update_available(plusheartbeat). Subscribe instead of polling when you need to react in real time (e.g. a billing or notification add-on reacting toblock_found). The stream also emits transport-level events your client should tolerate:helloon connect andlagged(with a count) when a slow consumer dropped out of the broadcast buffer — onlagged, refetch a snapshot instead of assuming continuity. - Live events (push) — outbound webhooks. Configure one or more
[[webhooks]]and the poolPOSTs a JSON payload to your URL when a matching event fires. This is the inverse of the SSE stream: instead of holding a connection open, your sidecar exposes an HTTP endpoint and the pool calls it. See the comparison below and Configuration Reference →[[webhooks]]. - Inbound data — the
generic_httpprovider pattern: your sidecar exposes a small JSON endpoint and the pool polls it. This is how the Solar bridge feeds live PV data into/api/energy. A clean way to push into the pool without touching its code. - Authenticated actions — for anything beyond public reads, use an API token (Admin → API tokens). Prefer a read-only token for least privilege (next section).
Webhook (push) vs SSE (pull) — which to use
Both carry the same event types; pick by how your sidecar wants to receive them:
Webhook ([[webhooks]]) | SSE (GET /api/events) | |
|---|---|---|
| Direction | Pool → you (pool initiates the POST) | You → pool (you hold the stream open) |
| Your side runs | an HTTP server (endpoint) | an HTTP client (long-lived GET) |
| Best for | serverless / no-code / cloud functions; rare low-frequency events | always-on local sidecars; high-frequency / every event |
| If your side is down | event is dropped after retries (no buffer) | you simply reconnect and resume |
| Auth model | optional HMAC signature (secret_env) | API token header (or none for public reads) |
| High-frequency events | opt-in; mind the POST-storm warning | fine — it’s a single stream |
Rule of thumb: a cloud function or no-code automation that can’t keep a
connection open → webhook. A persistent local process that wants every share
update → SSE. The webhook payload is
{ "event", "ts", "data" }, signed with X-WarpPool-Signature when a secret is
configured (verify it like a GitHub webhook). Loopback/LAN webhook targets are
allowed (there is no SSRF block — the URL is operator-trusted config), so a local
sidecar can receive POSTs at http://127.0.0.1:….
Authentication & least privilege
API tokens carry a scope:
admin(default) — full access, including mutatingPOST/PUT/DELETE.read— GET/HEAD/OPTIONS only. Any mutating request is rejected with403 Forbidden. A handful of especially sensitive operator endpoints (e.g. the Bitcoin-Core admin routes) rejectreadtokens outright, even onGET— those need anadmintoken or an interactive admin session.
Give an add-on a read token, not an admin one: even if the add-on (or
its host) is compromised, it cannot reconfigure the pool. Create one with:
curl -X POST https://your-pool/api/admin/tokens \
-H "Authorization: Bearer <admin-token-or-session>" \
-H "content-type: application/json" \
-d '{"name": "my-sidecar", "scope": "read"}'
The raw token is returned once — store it in the sidecar’s secret store.
API stability
- Stable surface: the public read endpoints listed above, the
/api/eventsSSE stream, and/api/version. Build add-ons against these. - Versioning: the project follows SemVer. Breaking changes to the stable
surface happen only on a MAJOR version bump. New fields may be added
within a minor release — parse defensively (ignore unknown fields). Recent
example: v1.21.0 added a
bannerobject (announcement marquee, set throughPOST /api/admin/banner) to/api/overview; a strict-schema client that rejects unknown fields would have broken on it, a defensive one did not. - Check the running version at
GET /api/version→{ current, latest, update_available }. Pin or feature-detect againstcurrentif you depend on a recent field. - Admin / mutating endpoints (
/api/admin/*) are operator tooling and may evolve faster; if you depend on them, pin to a specific release.
A minimal read-only sidecar (sketch)
import requests, sseclient # pip install requests sseclient-py
POOL = "http://127.0.0.1:18334"
# Option A — poll a snapshot every 10s
ov = requests.get(f"{POOL}/api/overview").json()
print(ov["pool"]["best_share_difficulty"], ov["active_miners"])
# Option B — react to live events
for ev in sseclient.SSEClient(f"{POOL}/api/events"):
if ev.event == "block_found":
... # do your thing — notify, bill, log
No token is needed for the public reads above; add an
Authorization: Bearer <read-token> header only if you later consume an
authenticated endpoint.
Architecture
dvb-WarpPool is a Rust workspace with 18 library crates + 6 binaries. A clear layering — from pure schema crates without async at the bottom up to the async-orchestrated binaries on top — keeps the system testable and lets sub-crates run standalone (e.g. the translator as a sidecar without the pool daemon).
Crate Overview
Schema + foundation (no async)
| Crate | What |
|---|---|
warppool-profiles | admin profiles — capacity hints + defaults. TOML values klein / mittel / gross / enterprise (Small ≤5 / Medium ≤20 / Large ≤100 / Enterprise >100 miners). These are recommendations for tuning threads, RAM target, retention and poll intervals — not hard caps; all four are hot-switchable at runtime |
warppool-config | TOML schema (PoolConfig + sub-configs + secrets) |
warppool-hwdetect | hardware detection via sysinfo → profile recommendation |
Core mining layer
| Crate | What |
|---|---|
warppool-bitcoin-rpc | JSON-RPC + ZMQ subscribe for Bitcoin Core |
warppool-bitcoin-ipc | opt-in Cap’n Proto mining client for Bitcoin Core >= 31 (low-latency template source; TemplateSource trait shared with the GBT path). Selected via [node].template_source = "ipc"; GBT stays the default, and Core’s own IPC mining interface is still experimental |
warppool-job-builder | coinbase tx (BIP34 height, SegWit commitment, BIP54-forward-compatible locktime) + merkle tree + Stratum job construction |
warppool-share-validator | share PoW check + block-found detection |
warppool-stratum-v1 | TCP listener, session state machine, VarDiff |
warppool-stratum-v2 | NOISE handshake, binary framing, mining subprotocol |
warppool-translator | V1↔V2 sidecar (a pool can offer Sv2 only; V1 miners connect via the translator) |
Storage + API
| Crate | What |
|---|---|
warppool-storage | SQLite via sqlx, all tables + migrations |
warppool-api | axum HTTP API (REST + SSE stream + static UI serving) |
Operational subsystems
| Crate | What |
|---|---|
warppool-health | Bitcoin Core multi-RPC health + bitcoin.conf parser + snippet generator |
warppool-autoupdate | version parser + forge release client + atomic_swap + cosign hook |
warppool-notifier | push sinks (ntfy/Telegram/Discord/Slack/email) + counter metrics |
warppool-telemetry | vendor API probes (AxeOS, NerdNOS, Avalon, …) + mDNS discovery + HTTP sweep + PoolMetrics |
warppool-uninstall | cross-platform uninstall plans (per-OS path tables, marker-gated Bitcoin Core targets) |
Tools
| Crate | What |
|---|---|
warppool-simulator | sim miners (vendor personas) + sim node + scenarios |
Binaries
| Binary | Purpose |
|---|---|
dvb-warppool-daemon | the pool — orchestrates all subsystems |
dvb-warppool-cli | operator tools (hash-password, token-create, set-profile, check-update, …) |
dvb-warppool-setup | first-run wizard (axum, embedded HTML) |
dvb-warppool-translator | V1→V2 sidecar (clap CLI, can run as a systemd service) |
dvb-warppool-sim | simulation runner (scenario list / run) |
dvb-warppool-mac-launcher | macOS app-bundle launcher (caffeinate wrapper, daemon supervision) |
Data Flow (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 |
+------------------+ +------------------+
| |
v v
V1 miners (Bitaxe, V2 miners / 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 Topology
dvb-warppool-daemon is a Tokio multi-reactor with roughly a dozen
long-lived tasks plus per-connection tasks. All tasks share one
CancellationToken for graceful shutdown.
+------- main() async -------+
| |
+- spawn ---------+----------+
| |
+-------+-------+ +------------------+
| Stratum-V1 | Stratum-V2 |
| accept loop | accept loop |
| (+ TLS) | (+ NOISE) |
+----------------+ +-----------------+
| |
per 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) |
| - Forge 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 |
+---------------------------------+
(Later phases added further opt-in loops on the same pattern: UPnP lease renewal, the Anker-bridge supervisor, and the daily energy aggregation.)
Shared State
Tokio tasks share Arc<...> handles instead of global statics:
| Handle | Type | Used by |
|---|---|---|
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 | all subsystems 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 writes; API reads |
profile_kind: Arc<RwLock<ProfileKind>> | hot-switchable | API admin route + display |
cancel: CancellationToken | propagation | all tasks (graceful shutdown) |
Storage Schema
Core tables from phases 1-15; the schema is created programmatically from a
bundled SCHEMA_SQL constant (CREATE TABLE IF NOT EXISTS …, run by
init_schema() in crates/storage/src/lib.rs) — there are no sqlx file
migrations. Later phases added the device tables
(miners, miner-telemetry raw/5min), the per-day energy history
(daily_aggregates) and goPool-import bookkeeping on the same pattern.
| Table | Phase | What |
|---|---|---|
workers | 1 | worker list (user, last_seen_at, shares_accepted/rejected, blocks_found) |
shares_raw | 1 | last hour of raw shares — basis for hashrate computation |
shares_agg_5min | 1 | aggregated 5-minute buckets — hashrate chart data |
blocks_found | 1 | block history (worker, height, hash, accepted, reject_reason) |
wallet_lifetime | 8 | per-wallet lifetime accumulator (sum_difficulty, expected_blocks, since) backing the odds panel’s expected_blocks/p_zero; written batched from the aggregate loop, watermarked by pool_settings.wallet_lifetime_last_rowid |
pool_settings | 2.5 | generic KV store (active_profile_kind, donation addresses, the v1.21.0 marquee banner text + expiry, etc.) |
vardiff_state | 2.5 | per-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 | persistent 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) |
Timestamp convention. DATETIME columns are written by SQLite itself
(CURRENT_TIMESTAMP → YYYY-MM-DD HH:MM:SS, space separator, UTC);
pool_settings string values are written by Rust as RFC3339 (T
separator). The two are not lexically comparable ('T' > ' '), so
cutoffs against DATETIME columns must be computed inside SQL
(datetime('now', '-N seconds')), never bound as Rust-formatted
strings — a v1.0.16 bug filtered out every active worker exactly this
way. The full rules live in the warppool-storage module docs.
The Sv2 Stack in Detail
Stratum V2 is a binary-framed protocol with a NOISE handshake in front. Two subprotocols matter in the pool context:
- Mining subprotocol (implemented) — channel-based, extranonce-aware, version rolling per BIP-320, per-channel set-target.
- Template Distribution Protocol (TDP; foundation in phase 7.6a,
wiring in 7.6b deferred) — replaces
getblocktemplatewith push-driven template updates straight from the Bitcoin node.
Standard vs. extended channels. Both are supported; pick by role.
Standard is header-only mining for end devices: the pool computes the
merkle root and ships ready-to-hash work — smaller frames, no merkle
work on the device. Extended hands the miner coinbase_prefix/suffix
plus the merkle path and full extranonce rolling, which is what proxies,
translators and farm aggregators need to manage their own search space
(our own translator opens an extended channel upstream for exactly that
reason). A single Bitaxe-class device gains nothing from extended —
nonce + version rolling is ample search space for its hash rate — and
pays with bigger jobs and per-job merkle work on the ESP32.
Recommendation: standard channel for solo devices, extended for
proxy/aggregation setups. VarDiff converges per connection either
way; a difficulty difference between the two modes is just convergence
state, not quality.
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 (sv2_listen, +
reference deployment :3336)
|
V1 miner ----TCP--+ |
| |
v |
dvb-warppool-translator (sidecar) --TCP+NOISE--------+
- SetupConnection
- OpenExtendedMiningChannel with user_identity
- SubmitSharesExtended with miner extranonce
- receives NewExtendedMiningJob + SetNewPrevHash
- maps to V1 mining.notify + slushpool prev_hash + BIP-320 version rolling
The concrete wire format lives in crates/stratum-v2/src/messages.rs with
roundtrip tests. The NOISE handshake uses the SRI noise_sv2 stack
(secp256k1 + EllSwift + Schnorr-signed certificates, spec-conformant since
the Sv2 modernisation) — pure Rust, no OpenSSL.
Connection Lifecycle Hooks
Both Stratum servers expose an optional ConnectionSink trait. For
authenticated workers (V1: mining.authorize, V2: OpenChannel with
user_identity) on_authorized fires; on connection end on_disconnect.
The daemon implements a NotifierConnectionSink struct that implements
both traits. A per-worker debounce (default 30s) keeps flapping miners
from firing a notification on every reconnect. The same hooks feed the
live worker sets (TLS/Sv2 badges) and the peer-IP refcount map used by the
energy accounting (“Disconnected” devices are excluded from watts/kWh).
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 is a tokio broadcast channel with 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) |
ProfileSwitched | API admin profile hot-switch |
Heartbeat | SSE keepalive with the current subscriber count |
The API’s /api/events opens one SSE stream per client with the current
subscription set. The UI uses it for HealthBanner + UpdateBanner +
toast events.
Configuration Layers
/etc/dvb-warppool/
├── config.toml # non-sensitive settings
├── secrets.toml # chmod 600 — admin hash, jwt secret, rpc pass, sv2 key
└── pool.db # SQLite (path configurable)
Env vars for opt-in subsystems (see the
Configuration Reference):
WARPPOOL_HEALTH_CHECK_INTERVAL_SECONDS, WARPPOOL_AUTOUPDATE_REPO,
WARPPOOL_DISCONNECT_DEBOUNCE_SECS, sink-specific tokens, etc.
Lifecycle (daemon boot)
- Parse CLI (clap) + ENV
- Load
config.toml+ (optional)secrets.toml - Validate config (
mining.payout_addressrequired,ratelimitconstraints, …) - Init tracing subscriber (JSON when
logging.json) - Open SQLite + run migrations
- Resolve admin profile (persisted value in
pool_settingsbeats the config default) - Construct
BitcoinRpcwithwith_metrics(pool_metrics) - Probe RPC (warning on failure; the daemon still starts)
- Construct
Notifierfrom config - Construct
PoolMetrics(Arc-shared) - Build the initial job (or skip when RPC failed)
- Spawn the Stratum V1 listener (+ TLS listener when cert/key configured)
- Spawn the Stratum V2 listener when
sv2_listenis configured (the authority key is auto-generated intosecrets.tomlif missing) - Spawn job_refresh_loop (poll + optional ZMQ)
- Spawn block_submit_loop
- Spawn aggregate_loop (60s)
- Spawn health_check_loop when
WARPPOOL_HEALTH_CHECK_INTERVAL_SECONDS > 0 - Spawn periodic_update_check when
WARPPOOL_AUTOUPDATE_REPO+ interval > 0 - Spawn the HTTP API (axum on
status_listen) - Install signal handlers (SIGTERM → cancel.cancel() → all tasks shut down)
- The
tokio::mainevent loop runs until cancel
Crate Dependencies (DAG)
Simplified crate graph (not every edge; no cyclic deps):
profiles --+
hwdetect --+--> config
|
+--> storage
+--> bitcoin-rpc --+
+--> bitcoin-ipc |
+--> job-builder |
+--> share-validator
+--> stratum-v1 -----+
+--> stratum-v2 -----+
+--> translator -----+
+--> telemetry -----+
+--> notifier ------+
+--> autoupdate ----+
+--> health --------+
+--> uninstall -----+
+--> api -----------+
+--> simulator -----+
|
v
dvb-warppool-daemon
dvb-warppool-cli
dvb-warppool-setup
dvb-warppool-translator
dvb-warppool-sim
dvb-warppool-mac-launcher
Crates and their one-directional deps:
telemetry← none (foundation for metrics)bitcoin-rpc→telemetry(for the RPC latency histogram, phase 16.3)api→notifier+autoupdate+telemetry+storage+ …daemon→ all library crates plus runtime deps (tokio, sqlx, …)
Testing Strategy
Three layers, plus one extra operator-driven layer:
| Layer | What | Tool |
|---|---|---|
| Unit | pure logic per crate | cargo test -p warppool-<crate> |
| Integration | multiple crates in-process, axum mock servers | tests/ per crate |
| Sim | against the real daemon process, vendor personas | dvb-warppool-sim scenario <name> |
| Regtest E2E | against a real bitcoind regtest | scripts/regtest-up.sh + --ignored (4 tests, opt-in) |
As of v1.24.0: 1201 tests + 4 ignored, all green. UI side:
pnpm svelte-check → 374 files, 0 errors.
See TESTING.md for details + the simulator workflow.
dvb-WarpPool — UI Design Concept
Research snapshot: May 2026. Direct site fetches were not available — all findings come from web-search synthesis + review sources. The concepts are not copies but an inspiration synthesis.
1. Findings
atlaspool.io
- Address-first flow: wallet address as a search input at the top, no login. Visibility toggle for screenshots — smart.
- Stats stack: real-time hashrate, mining streak, pool contribution, active workers, best share, first-seen, est. block time, achievements (gamification, announced for 2026).
- Network context: block height, network hashrate, difficulty, BTC price as a glance row — good, because mining without network context is meaningless.
- Hashrate graph with multiple timeframes: 1D/1W/1M/All — standard, but clean.
- Refresh every 2 min — no real-time push, polling instead. Room for improvement.
ocean.xyz
- Permissionless, address-keyed: no accounts. Stats stick to the payout address.
- “Shares in reward window”: the TIDES-specific metric is prominent — they make their payout logic visible.
- Quick-read metrics (daily earnings, lifetime, progress-to-payout, unpaid) as cards. Not glamorous, highly informative.
- Updated Feb 2026 — UI rather functional-conservative, no show-off.
DeepSea dashboard (community frontend for Ocean)
- Retro CRT terminal aesthetic: scanlines, phosphor glow — deliberately nostalgic.
- Three themes: DeepSea (blue), Bitcoin (orange), Matrix (green). Theme as identity.
- Chart.js with block annotations, worker cards with ASIC detection, pool donut, sound effects on new block.
- Atmosphere over information density — emotional engagement through audio + visuals.
Braiins / general 2026 trends
- Zoomable hashrate charts, regex filtering for workers, API-first.
- The 2026 split: techno-futurist (dark + neon + shaders + bento) vs. editorial (cream + serif + whitespace). Crypto almost always sits in the first camp.
- Sidebar + cards (the Linear/Vercel/Stripe pattern) scales without restructuring.
- Glassmorphism: used sparingly by now; brutalism is gaining as a counter-movement.
2. Three Design Concepts
Concept A — “Warp Drive” (cinematic cosmos)
- Style: glassmorphism + deep space. WebGL stars in the background
(subtle, ≤40 particles), shader gradients à la Stripe/Vercel, glass
cards with
backdrop-filter. - Palette:
#05060B(void),#0F1226(deep),#7B5CFF(warp violet),#00E0FF(plasma cyan),#F7931A(BTC orange as accent, used sparingly). - Type: headers Space Grotesk (700, tight tracking). Body Inter (15px). Numerics JetBrains Mono (tabular-nums).
- Layout: 12-column bento grid, asymmetric. Hero = large live-hashrate block (4×3), small cards for workers/block history.
- Data viz: animated counters (Web Animations API), sparklines with gradient fill, hashrate chart with scroll-driven annotations for found blocks.
- Modern techniques:
- View Transitions API for address lookup → dashboard
- CSS
@scroll-timelinefor block-history reveal color-mix()for theme-reactive glow states- Popover API for the worker detail drawer
- Mobile: bento collapses to a single-column stack via container
queries. WebGL shaders fall back to a static gradient under
prefers-reduced-motion. - Pros: emotional, “future of mining”, clearly differentiates from the predecessor. Risk: WebGL performance on old phones; glass can ruin readability if the background gets too busy.
Concept B — “Stratum” (brutalist terminal)
- Style: monospace-first, hard lines, visible grid, ASCII accents. Inspiration: Linear logs + Bitaxe AxeOS + Recurse-Center vibe.
- Palette:
#0A0A0A(true black),#1A1A1A(panel),#E8E8E8(text),#39FF14(signal green for “alive”),#FF4D00(alert orange). Pure, four tones. - Type: entirely Berkeley Mono or JetBrains Mono. Headers larger (28-40px), body 14px. No second family.
- Layout: sidebar (240px) + long-scroll main panel. Sections styled
like terminal output with
─────separators. Command palette (⌘K) as the central navigation. - Data viz: ASCII-style sparklines (
▁▂▃▅▇▆▄▂), heatmaps as CSS grid withcolor-mix(), block history as a log stream with timestamps. - Modern techniques:
text-wrap: balancefor headlines- Container queries instead of breakpoints — every card adapts locally
- Anchor positioning for tooltips on chart data points
@propertyfor animatable CSS variables (hashrate pulse)
- Mobile: the sidebar becomes a top bar with a hamburger; long-scroll stays — brutalism scales honestly.
- Pros: technically credible (matches the Rust backend), extremely performant, ages well. Risk: too sober for a Bitcoin-maxi audience expecting “wow”; onboarding is harder for non-techies.
Concept C — “Aurora Bento” (editorial neo-bento)
- Style: bento grid with generous whitespace, soft cream against a deep-night-mode toggle, mesh-gradient accents. A mix of Apple bento + Stripe mesh + Notion editorial.
- Palette: dark default:
#0B0D10,#15181D,#F2EFE8(warm cream text),#FF7A1A(BTC orange as hero accent),#A8FF60(signal lime). Light mode inverts cleanly. - Type: headers General Sans (display, 700). Body Inter (16px, generous). Numerics Geist Mono.
- Layout: bento with 3 size classes (S/M/L), clear hierarchy. Hero card = hashrate, M = worker grid, S = BTC price / block height / difficulty.
- Data viz: soft animated counters (spring physics via WAAPI), area charts with mesh-gradient fill, worker cards with mini donuts for share contribution, block-found as a confetti burst (CSS motion-path).
- Modern techniques:
- CSS
motion-pathfor the block-found animation - View Transitions between light/dark
color-mix()+oklch()for perceptually uniform theme shifts- Container queries for bento card adaptation
- CSS
- Mobile: bento reflows into a magazine scroll, large tap targets, prominent PWA install prompt.
- Pros: accessible for newcomers + deep enough for pros, photographs well (marketing!), light/dark equally strong. Risk: bento is slightly worn out in 2026 — differentiation has to come from craft + micro-interactions, otherwise it’s “yet another SaaS dashboard”.
3. Recommendation
Concept A “Warp Drive” — because the name WarpPool already sets the direction and the users explicitly want to move away from the classic predecessor look. Warp Drive uses the most modern web capabilities (View Transitions, scroll-timeline, color-mix, WebGL) without falling into bento arbitrariness (C) or terminal harshness (B). It delivers the “wow” for marketing screenshots while staying functionally usable — as long as glass effects are applied with discipline (cards only, never on charts). Fallback path: if performance tests fail, concept C is a clean retreat with a similar palette. Concept B can land later as a power-user theme option (“Terminal Mode”).
4. Implementation Status (as of v1.24.0)
Concept A is implemented in ui/ (SvelteKit 2 + Svelte 5 runes,
adapter-static, SPA mode). What is actually live:
Routes
| Route | Content |
|---|---|
/ | dashboard with the pool-stats bento (hashrate chart, pool stats, network, blocks, top miners) |
/overview | aggregated overview (“Übersicht”) |
/blocks | block table with pending/accepted/rejected status |
/wallets | wallet-prefix list — nav label “Workers” |
/workers | per-miner list with user agent + last share — nav label “Miners” |
/miners | configured device list + discovery — nav label “Devices” / “Geräte” |
/miners/[id] | per-device detail + telemetry history |
/hardware | live detection + profile picker + the profile-comparison table |
/hashrate | historical hashrate chart (1d / 1w / 1m) |
/users/[address] | login-free per-wallet page for the per-worker payout mode (ckpool-compatible path), with BIP-137 ownership verification for the privacy/notify settings |
/login | auth page (username/PW + optional 2FA code) |
/admin | hub for profile switch, backup, tokens, 2FA, audit, notifications |
/admin/notifications | browser-push settings + server-side sinks — per-sink test buttons with badge state (neutral / ok / err) + last-error tooltip |
/admin/banner | v1.21.0 — marquee announcement editor (text + expiry); served to every visitor via the banner field in /api/overview |
/admin/{profile,tokens,audit,2fa,backup,bitcoin-core,donations,health,mining,update} | admin surfaces (profile incl. the manual IP ban-list panel) |
Components
| Component | What |
|---|---|
Starfield.svelte | CSS-animated drift (60 stars + 4 glow decals, alpha .35-.95) |
BentoCard.svelte | glassmorphism with backdrop-filter: blur(8px) |
StatTile.svelte | animated counter (Web Animations API) |
Badge.svelte | tone-aware pill (ok / warn / err / neutral) |
HashrateChart.svelte | hand-rolled SVG chart — auto-scaling Y-axis, gradient fill, best-share dots on a separate right axis |
HealthBanner.svelte | Phase 14 — consumes SSE health_snapshot + update_available, persistent dismissable banners below the header |
EventToasts.svelte | block-found / profile-switch toast notifications |
LocalePicker.svelte | 8-locale dropdown with emoji flags |
BestSharesCard.svelte | best-share leaderboard |
LayoutTools.svelte | card-grid toolbar — reset a custom card layout + (admin) save the current arrangement as the pool-wide default |
IpBanlist.svelte | manual IP ban-list panel (Admin → Profile) |
EnergyCard.svelte | energy / solar tracking tile |
ConnectModal.svelte | “connect a miner” endpoints + Sv2 pubkey, with copy buttons |
TelemetrySparkline.svelte | per-miner temperature / hashrate sparkline |
UpdateStatusBadge.svelte | auto-update availability badge |
Screensaver.svelte | full-screen idle screensaver — looping splash video + live stat bar (best share, hashrate, miners, blocks, version), for wall/kiosk displays |
ScreensaverSettings.svelte | top-bar “Display” popover (next to the locale picker) — toggles the screensaver per device (on/off, idle timeout, stats overlay) |
Marquee.svelte | v1.21.0 — scrolling announcement banner, fed by the banner field of /api/overview, pauses on hover and respects prefers-reduced-motion |
DonationCard.svelte | operator donation addresses (configured under Admin → Donations) |
Modern web tech actually in use
- ✅ View Transitions API for route changes
- ✅
color-mix()for theme-reactive glow states - ✅ Container queries for the mobile bento reflow
- ✅ SSE (EventSource) for live push via
/api/events - ✅
backdrop-filter+ glass cards - ⏸ WebGL stars: deliberately CSS animation instead of WebGL —
prefers-reduced-motionfallback + performance on old phones - ⏸
@scroll-timeline+motion-path: not yet implemented (phase-B polish once browser support is broad) - ⏸ Popover API: not yet. Nor the native
<dialog>element — modals (e.g.ConnectModal.svelte) are a backdrop<div>plus arole="dialog"/aria-modal="true"panel, with focus and Escape handled in the component. Moving to<dialog>or the Popover API would hand that work to the platform; it has not been done yet.
Internationalization
8 locales via JSON files in ui/src/lib/locales/:
de(German — primary)en(English — fallback)es(Español),pt-BR(Português Brasil)fr(Français),it(Italiano)ja(日本語),zh(中文)
The i18n system is hand-rolled (~150 LOC in i18n.svelte.ts) — a runes
store + t('foo.bar', {name: 'X'}) with interpolation, no heavy
framework. Locale detection: localStorage > navigator.language > ‘en’.
Every user-visible string must go through i18n.t() — no hardcoded copy and
no German fallbacks. ui/scripts/i18n-guard.mjs (key parity across all 8
locales, key existence, umlaut scan) runs as part of npm run check and
therefore gates CI.
PWA
app.webmanifest+ icon set (192/512)service-worker.ts(cache-first for static assets, network-first for/api/*)- Install prompt prominent on the login screen
- Browser Notification API for foreground block-found events
- VAPID web push — subscriptions in
push_subscriptions, the daemonpush_send_loopdelivers (shipped in Phase 21)
Where the implementation deviates from the original concept
- Starfield is CSS, not WebGL — performance on RPi 5 / old phones was the deciding factor. Visually near-identical at a fraction of the GPU load.
- Glass cards applied with discipline — only on BentoCard, never on tables or charts (readability).
- Bento stays 12-column — no dynamic reflow to 6/4/3 cols. Container queries make the cards themselves adaptive.
- Brand accent is not orange —
--warp(violet) is the primary; orange is reserved for BTC references. The concept-A palette is implemented 1:1.
Performance status
- Type-check surface: 374 files via
svelte-check(npm run check, which also runs the i18n guard) — gating CI - Initial bundle: ~250 KB JS + ~30 KB CSS (gzipped)
- Lighthouse score (on a typical pool setup): ~95 performance, 100 accessibility, 100 best practices, 100 SEO
- Hashrate chart: hand-rolled SVG
<path>— lightweight even at 1000+ buckets
Known limitations
- Pre-existing CSS warnings (unused selectors, a11y hints) — historical artifacts, no functional impact
- No real dark/light theme toggle (the UI is always dark — concept A deliberately specs no light mode)
- A light-mode variant would be non-trivial because of the
backdrop-filter+ glassmorphism look
Testing & Simulation
Four test layers, all verified pre-release. As of v1.24.0 (2026-07-24): 1201 Rust tests green + 4 ignored (regtest opt-in), 374 svelte-check files / 0 errors / 0 warnings.
1. Unit Tests
Per crate, pure logic. cargo test --workspace covers all 18 library
crates + 6 binaries.
Key test counts (as of v1.24.0 — #[test] / #[tokio::test] attributes per
crate; the authoritative total comes from cargo test --workspace):
| Crate | Tests | What |
|---|---|---|
warppool-api | 252 | axum handler tests against in-memory SQLite + mock GitHub/SMTP servers, incl. pool_metrics + notifier, API rate-limit middleware, GBT/IPC template-source admin switch, per-wallet odds panel (Poisson kernel + endpoint) |
warppool-stratum-v2 | 138 | NOISE handshake, codec roundtrips, session frames, PoW check, stale-share detection, VarDiff parity (idle decay + best-share, incl. direct Standard-channel seeding), job-history (template-refresh tolerance), address-allowlist (Standard + Extended channel-open) |
warppool-storage | 117 | sqlx roundtrips, migrations, all tables, vardiff, audit_log, api_tokens, admin_2fa, pool_settings, goPool import, pool-size + peek_setting, single-transaction record_share + worker eviction, correlated solar/grid energy split, wallet_lifetime odds accumulator, share-outcome (personal-best + top-100 entry) |
warppool-stratum-v1 | 152 (+8 e2e) | session state machine, rate limit (incl. live auth-rate switch, CIDR IP-gate, per-IP connection cap, reject-ratio + ban-when-disabled enforcement), address-allowlist on authorize, VarDiff (per-class incl. heavy-iron max_diff, suggest/d= honoring, idle decay, EMA rescale on retarget), ntime lower bound, worker-name cap, TLS roundtrip |
warppool-telemetry | 75 | vendor probes (AxeOS / cgminer mock / Avalon estats), mDNS discovery, HTTP sweep, PoolMetrics + histogram |
dvb-warppool-setup | 51 | wizard endpoints, install marker, Umbrel env defaults, goPool path resolver |
warppool-config | 56 | TOML schema, coinbase_splits, VarDiffSettings/RetentionConfig/AutoProfileConfig::validate, retention/upnp blocks, comment-preserving save, profile-tier mapping |
warppool-health | 35 | RPC call, bitcoin-conf parser, snippet generator, warnings aggregation |
warppool-notifier | 51 | render_text, EmailSink/SlackSink construction, sink skip when env missing, MarkdownV2 escaping of miner/operator fields |
warppool-autoupdate | 41 | version compare, asset matching, download_verified, atomic_swap, cosign subprocess |
warppool-share-validator | 26 | sha256d vectors, dedup, network/pool target checks |
warppool-bitcoin-rpc | 37 (+3 regtest, #[ignore]) | RPC envelope parsing, ZMQ frames, submit_block null result, transient-error retry, multi-node failover + broadcast |
warppool-bitcoin-ipc | 12 | Cap’n Proto mining client for Bitcoin Core >= 31, TemplateSource trait, GBT/IPC selection + socket precheck |
warppool-uninstall | 22 | per-OS path plans, marker gating, JSON roundtrip |
warppool-job-builder | 26 | coinbase construction, BIP-34 height encoding, merkle branches, template mintime |
warppool-translator | 13 (+5 e2e, +3 bin) | extended-channel state machine, BIP-320 version rolling, prev-hash reverse |
warppool-hwdetect | 11 | sysinfo detection, container env, recommendation reason keys |
warppool-profiles | 7 | profile resolution, retention monotonicity, rpm→bucket rate derivation |
warppool-simulator | 4 | sim-miner personas, scenarios |
(The binaries carry their own glue-path tests: daemon 44 — supervisor loops,
notifier hydration, wallet-moment cooldown, auto-backup loop, plus one
#[ignore] per-worker regtest consensus E2E — CLI 7 for argument plumbing, and
the macOS launcher 5.)
# Everything
cargo test --workspace
# A single crate
cargo test -p warppool-share-validator
# With output
cargo test -p warppool-stratum-v2 -- --nocapture
2. Integration Tests
Multiple crates in-process, axum mock servers for external services. Per
crate under tests/.
Typical cases:
| Test | Crate | What |
|---|---|---|
e2e.rs | stratum-v1 | TCP listener + ShareValidator + sim-miner roundtrip with a real subscribe/authorize/submit flow |
regtest_e2e.rs | bitcoin-rpc | #[ignore] — against a running 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 against the Prometheus format |
3. Simulation (dvb-warppool-sim)
Runs against the real daemon process (or spawned in-process). Scenarios are end-to-end stories.
Built-in scenarios
| Scenario | Duration | Status | What it verifies |
|---|---|---|---|
solo-block-found | 30s | ✓ executable | sim miner connects, block-found event fires |
connection-storm-defense | 60s | ✓ executable | rate limit kicks in under N parallel auth attempts |
enterprise-load-smoke | 5min | ✓ executable | N parallel miners of mixed personas run cleanly |
small-24h-stability | 24h | plan-only | Small profile stays under 200 MB, no panics |
rpc-outage-recovery | 10min | plan-only | pool survives a 5-minute RPC outage |
Invocation
solo-block-found runs self-contained — it spawns a Stratum V1 server
in-process with network_target=[0xff;32] (every valid share becomes a
BlockFound) and a JsonStratumMiner (persona BitaxeUltra). No Bitcoin
Core required:
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 fired 190x (burst allowance was 10)
dvb-warppool-sim scenario enterprise-load-smoke --duration 15 --miners 200
# → Accepted: 57400 (~3826 shares/s), Rejected: 0, Disconnects: 200
Personas
Sim miners mimic real hardware (hashrate, user agent, TLS support, reconnect pattern):
| Persona | Nominal hashrate | TLS | Note |
|---|---|---|---|
nerdminer-v2 | 50 GH/s | no | plain only |
bitaxe-ultra | 2 TH/s | yes | clean reconnect |
nerd-octaxe | 4.5 TH/s | yes | NerdNOS quirks |
avalon-q | 90 TH/s | no | CGMiner user agent |
antminer-s23-pro | 580 TH/s | yes | overt ASIC boost |
adversary | — | — | malicious inputs |
Failure modes
dvb-warppool-sim failures <mode> injects production problems:
rpc-down— Bitcoin Core unreachablezmq-disconnect— block notify gonetls-handshake-fail— expired certminer-hang— connection stalls after N sharesnetwork-latency— artificial RTTpacket-loss— N% packet lossinvalid-template— broken coinbase txreorg— tip gets rolled backmemory-pressure— pool under RAM pressuredisk-full— SQLite write failure
Security suite
dvb-warppool-sim security drives adversarial inputs (line flood,
connection storm, slowloris, auth flood, weak ciphers, …).
Correct outcome: every connection rejected cleanly, no panic, no OOM.
4. Regtest E2E (Phase 15b — opt-in)
For full E2E against a real bitcoind regtest:
eval "$(scripts/regtest-up.sh)" # starts bitcoind regtest + sets env vars
cargo test -p warppool-bitcoin-rpc --test regtest_e2e -- --ignored --nocapture
scripts/regtest-down.sh --purge # cleanup
Four #[ignore] tests in total — three in warppool-bitcoin-rpc:
| Test | What |
|---|---|
regtest_blockchain_info_returns_regtest_chain | chain == "regtest" |
regtest_getblocktemplate_works | template carries version + bits + prev hash |
regtest_submit_invalid_block_is_rejected | trivially invalid hex → Rejected (not Accepted, not Error) |
…plus a full consensus E2E in the daemon crate
(per_worker_regtest_full_consensus_e2e, apps/dvb-warppool-daemon/src/regtest_e2e_tests.rs):
it mines a real per-worker-coinbase block against regtest and asserts Bitcoin
Core accepts it, the reward is attributed to the right worker, the witness
commitment is correct and no cross-payment happens.
cargo test -p dvb-warppool-daemon --lib -- --ignored --nocapture per_worker
CI does not run them (no bitcoind in the runner image). Locally they are very useful before release tags. See Configuration Reference → WARPPOOL_E2E_REGTEST_*.
5. Performance Benchmarks (Phase 19 — opt-in)
Criterion suites for the hot paths:
| Crate | Bench | Hot path |
|---|---|---|
warppool-share-validator | validate | per accepted+rejected share |
warppool-job-builder | build_job | per new template |
warppool-stratum-v1 | vardiff | per accepted share |
cargo bench --workspace
# or
cargo bench -p warppool-share-validator --bench validate
See Performance Benchmarks for baseline numbers and interpretation.
6. UI side (svelte-check)
cd ui
pnpm install
pnpm svelte-check
# → 374 files / 0 errors / 0 warnings
svelte-check is clean — 0 errors, 0 warnings.
7. Fuzzing (opt-in)
The inbound Stratum parser reads bytes straight off the wire, so it is the prime hardening target. Two layers:
-
Stable smoke fuzz (runs in the normal suite, no nightly):
messages.rs::tests::fuzz_parse_never_panicsdrivesStratumRequest::parsesanitize_json_escapesthrough 20,000 deterministic mutated / pathological inputs (random bytes, byte-mutated valid messages, deep nesting, escape storms) and asserts none of them panic.
-
Coverage-guided fuzz (
crates/stratum-v1/fuzz/, needs nightly +cargo install cargo-fuzz):cargo +nightly fuzz run parse_stratum # from crates/stratum-v1/The
fuzz/crate has its own workspace root, so the maincargo build/test --workspacenever pulls in the nightly-only dependency. Crashes go intofuzz/artifacts/; minimise and add the input as a regression case infuzz_parse_never_panics.
CI
CI runs on a self-hosted Forgejo runner (git.warppool.org); the workflow
files still live under .github/workflows/ but are executed by Forgejo
Actions. There are no macOS and no Windows runners — everything below is
Linux-only. See CI porting notes.
.github/workflows/ci.yml matrix:
cargo fmt --check(no diff)cargo clippy --workspace --all-targets -- -D warnings(a few style lints allowed:field_reassign_with_default,get_first,too_many_arguments)cargo test --workspacecargo deny check(licenses + advisories — RustSec feed via deny)- UI job:
pnpm install+pnpm svelte-check+pnpm build
Separate workflows (see Architecture):
release.yml— tag-triggered: multi-arch Docker + native Linux binaries + SBOM. No macOS/Windows jobs — the.dmg/tarball assets are built manually per release withpackaging/macos/build-macos-release.sh, the.msiis pausedrepro.yml— reproducible-builds verify (build twice + sha256 diff)docs.yml— mdBook → docs.warppool.orgbenches.yml— Criterion, manual dispatch only (not per-PR, not on tag push: the fat-LTO bench profile OOM-compiles on the CI box and benches must never gate a release)
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 (quick smoke before a long suite)
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-06-11, v1.0.18, 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.
~4-7× faster than the 2026-05-27 snapshot. The June dependency round bumped
sha2to 0.11, whose rewritten backend uses the CPU’s SHA2 intrinsics on Apple Silicon / x86_64 — every SHA-dominated path (share validate, merkle root, job build) gained accordingly. VarDiff does no hashing and is unchanged.
validate (per share)
| Bench | Time | Throughput |
|---|---|---|
validate_full/0 (no merkle branches) | 435 ns | 2.3M shares/s |
validate_full/8 (typical regtest) | 1.12 µs | 890K shares/s |
validate_full/12 (typical mainnet) | 1.48 µs | 677K shares/s |
sha256d_80b_header | 76 ns | — |
sha256d_500b_coinbase | 245 ns | — |
merkle_root/12 | (hot-path portion) ~1.0 µs | — |
reconstruct_coinbase | ~34 ns | — |
build_header | ~4 ns | — |
Take-away: validate scales with merkle-branch count (linearly). At 12 branches the pool can validate ~677K shares/s — five orders of magnitude above what a solo fleet produces (typically 1-5 shares/s). Validate is NEVER the bottleneck.
build_job (per job-refresh)
| Bench | Time | Throughput |
|---|---|---|
build_job/0 (empty / regtest) | 1.25 µs | — |
build_job/100 | 18.2 µs | — |
build_job/1000 | 161 µs | — |
build_job/4000 (typical full block) | 634 µs | 1.5K jobs/s |
merkle_branches/4000 | 318 µs | — |
Take-away: Job-build scales with tx-count, dominated by merkle-branch computation. 634 µs / 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) | 39.0 ns (~5ns/share) |
difficulty_to_target_be | ~3.5 ns |
vardiff_decision_variant_match | ~340 ps |
Take-away: VarDiff is effectively free. Even under extreme load scenarios (>100K shares/s) it consumes <1ms/s of CPU.
Cross-Check (2026-06-12, v1.0.23, Umbrel Home — AMD Ryzen 7 8745HS, Debian 13)
Same suites, measured on a production Umbrel box while the pool and Bitcoin Core kept running (realistic operator conditions, ~5 GB RAM in use). Same order of magnitude as the M-Series snapshot — no regression; the per-path differences are silicon, not code:
| Bench | M-Series (v1.0.18) | Ryzen 8745HS (v1.0.23) |
|---|---|---|
validate_full/0 | 435 ns | 334 ns |
validate_full/8 | 1.12 µs | 1.15 µs |
validate_full/12 | 1.48 µs | 1.56 µs (~642K shares/s) |
sha256d_80b_header | 76 ns | 92 ns |
sha256d_500b_coinbase | 245 ns | 252 ns |
build_job/0 | 1.25 µs | 0.69 µs |
build_job/4000 | 634 µs | 598 µs |
merkle_branches/4000 | 318 µs | 385 µs |
vardiff_observe_share_hold | 5.2 ns | 3.3 ns |
vardiff_observe_share_retarget | 39.0 ns | 28.4 ns |
difficulty_to_target_be | ~3.5 ns | ~5.3 ns |
The paths benched here are untouched since the v1.0.18 snapshot (releases 1.0.19–1.0.23 changed UI, API, storage queries and stratum I/O — none of it on these paths), so matching numbers were expected; the box run confirms it on x86_64/SHA-NI.
End-to-End Load Test (2026-06-13, post-v1.1.0, Apple M2 Pro 32 GiB)
The Criterion suites above measure single functions. This section
measures the whole serving path under swarm load, using the
dvb-warppool-sim simulator in two setups:
- Stratum core, in-process —
scenario enterprise-load-smokespawns a StratumServer plus N simulated miners in one process (VarDiff off, difficulty 1.0, every submit accepted): real TCP, real JSON parsing, real sha256d share validation, no DB. - Full daemon, external —
load <profile> --target …fires the miner swarm at a release daemon running against a regtest Bitcoin Core v31 (cookie RPC + ZMQ), i.e. the complete production pipeline: connection handling, class detection, VarDiff, validator, SQLite share sink, job broadcasts and the block-submit loop.
Simulated miners speak genuine Stratum V1 but do no real proof-of-work: submits are timer-driven from the persona’s nominal hashrate and carry random nonces. Plain TCP only (the sim client does not implement TLS).
Stratum core throughput (in-process)
| Miners | Duration | Accepted shares | Rate | Rejected |
|---|---|---|---|---|
| 100 | 60 s | 27,667 | ~461 shares/s | 0 |
| 1,000 | 60 s | 277,256 | ~4,621 shares/s | 0 |
Throughput scales linearly with the swarm (the rate is set by the
personas’ submit cadence, not by the server — validate alone clocks
~650K shares/s single-threaded, see above). No backpressure, no
disconnects, zero rejects at 4.6K shares/s sustained.
Full daemon under swarm load (external process)
| Profile | Miners | Submits processed | Daemon RSS peak | Daemon CPU |
|---|---|---|---|---|
| farm-large | 100 | ~114 /s | ~48 MB | ~4–5 % of one core |
| farm-enterprise | 1,000 | ~1,295 /s | ~47 MB | ~28–37 % of one core |
Both runs: zero stale shares, zero unexpected disconnects, all miners connected through a ~2 s ramp and stayed connected for the full minute.
Reading the accepted/rejected split. On regtest the network target
is the powlimit, so a random nonce “finds a block” with ~50 %
probability — roughly half the submits were accepted as block-found
events, the other half correctly rejected as above pool target (random
nonces practically never meet a difficulty-1+ pool target). Real miners
only submit hashes that already meet the pool target, so on mainnet
this split becomes ~100 % accepts. The load-relevant number is the
processed-submits rate: every submit walks the full validation path
either way. A side effect worth noting: the ~50 % block hits made the
daemon fire submitblock storms at Bitcoin Core, with concurrent
blocks racing each other (losers logged as inconclusive) and every
win triggering a clean-jobs broadcast to all 1,000 miners — the pool
stayed healthy through job churn far beyond anything mainnet produces.
Reproduce:
# in-process core
dvb-warppool-sim scenario enterprise-load-smoke --miners 1000 --duration 60
# full daemon (point --target at a NON-production instance!)
dvb-warppool-sim load farm-enterprise --duration 60 --target tcp://127.0.0.1:13333
Template Source Latency — IPC vs GBT+ZMQ (2026-07-08, Bitcoin Core v31.1, regtest)
Not a Criterion micro-bench: this measures how fast a fresh block template
is ready in the pool after a new chain tip, comparing the default
getblocktemplate + ZMQ path against the opt-in Bitcoin Core IPC template
source (Cap’n Proto, Core ≥ 31, [node] template_source = "ipc", new in
v1.16.0). Release build, Core v31.1 from the official release binary, 50
rounds, median.
| Template | GBT + ZMQ (default) | Core IPC (opt-in) |
|---|---|---|
| empty block | 88.9 ms | 5.0 ms |
| 320 transactions | 101.4 ms | 12.3 ms |
The gap is not CPU — it’s the notification path. Verified with Core’s
-logtimemicros: Core’s ZMQ publish lags the UpdateTip by ~85 ms
(it rides the validation queue / scheduler), so a GBT consumer only learns
about the new tip ~85 ms late and then still has to call getblocktemplate.
The IPC path’s waitNext hangs directly on the kernel’s block-connected
notification, so it returns the moment the tip changes.
Honest reading: this is a latency improvement, not a hashrate one. For a solo pool it shrinks the window in which miners keep grinding a stale template right after a block change — fewer wasted hashes at the block boundary, nothing more. It is opt-in and needs Bitcoin Core v31 (the v30↔v31 IPC schema is wire-incompatible), and Core’s IPC mining interface is itself still experimental; GBT stays the default and is unaffected. See the IPC design note for the schema-maintenance trade-offs.
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: ~15 µs share-validate time per second = 0.0015% CPU |
| “How many workers can my pool serve at most?” | The Stratum connection cap (profile-dependent, 64-4096). Share-validate is not the limit — the load test above shows 1,000 live miners at ~⅓ of one core and <50 MB RSS |
| “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 — executed by Forgejo Actions on the
self-hosted runner — runs only on manual dispatch (operator clicks “Run
workflow” in the Actions UI on git.warppool.org). It is deliberately not
triggered on tag push (the fat-LTO bench profile OOM-compiles the big crates on
the CI box, and a red bench run must never make a published release look
failed) and not on every PR. Criterion runs are expensive (~5min build +
5min suite), and CI-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 CI. 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. Covered at the swarm level by the end-to-end load test above; tokio-console 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 feature-complete 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) | ⏳ deferred — superseded by the Core IPC template source (v1.16.0) | — |
*Each phase additionally turned tests green. Workspace total: 1201 tests + 4 ignored (as of v1.24.0).
Scope note: these phases build the Sv2 Mining Protocol. Sv2 Job Negotiation / Job Declaration is not implemented — see the section at the end of this page.
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 over the SRInoise_sv2handshake / transport. Pattern:Noise_NX_Secp256k1+EllSwift_ChaChaPoly_SHA256(2024+ Sv2 spec).
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(0x15) — standard channels (pre-computed merkle_root)NewExtendedMiningJob(0x1F) — 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 (still deferred — solved another way)
The original plan was to wire the Phase-7.6a TDP messages against a Bitcoin Core that speaks Sv2 Template Distribution natively. That never happened: Core decided against native Sv2 support and instead exposes a generic, experimental Cap’n Proto IPC mining interface (Core ≥ 30, usable for us from Core ≥ 31).
The low-latency template problem was therefore solved via IPC instead, and
shipped in v1.16.0: the opt-in warppool-bitcoin-ipc template source
feeds the same job_refresh_loop / JobBuilder path through a shared
TemplateSource trait, with GBT+ZMQ remaining the default. See
Comparison and the benchmarks page for the measured numbers.
Consequence for this phase: the IPC backend talks Cap’n Proto to Core directly and does not use the TDP wire messages, so the Phase-7.6a messages stay implemented-but-unused. The TDP path remains deferred with no scheduled date.
Not implemented: Sv2 Job Negotiation / Job Declaration
Worth stating plainly, because “Stratum V2” is often read as “all of Sv2”: dvb-WarpPool implements the Mining Protocol (plus the Phase-7.6a Template Distribution message definitions). The Job Negotiation / Job Declaration Protocol — the part that lets miners choose their own transaction set — is not implemented and is not currently scheduled.
Miners connecting over Sv2 get pool-built jobs, exactly as they do over V1. If miner-side transaction selection is your reason for wanting Sv2, this pool does not yet provide it.
Phase 8 — Done
All Phase 8 items shipped (per-release detail in CHANGELOG.md / ROADMAP.md):
- Reproducible builds (deterministic timestamps,
--remap-path-prefix) ✅ - Cosign signatures on
SHA256SUMS(project key; keyless OIDC ≤ v1.15.0) ✅ - SBOM (SPDX, via
anchore/sbom-action) ✅ - Auto-update with systemd
OnFailurerollback (warppool-autoupdatecrate) ✅ - Documentation site (this handbook, Phase 8a) ✅
Roadmap
How to read this file. Everything below Phase 32 is a historical build log, kept deliberately: each phase records what was done, which bugs were found, and why decisions went the way they did. The “THIS SESSION” markers are historical, not current. For where the project stands today, read the two sections directly below.
Current status — v1.24.0
Release v1.24.0, 1201 Rust tests + 4 ignored, svelte-check across
374 files. Source, container registry and releases live on the project’s
own Forgejo at git.warppool.org; the handbook is published at
docs.warppool.org.
pool.warppool.org runs the public per-worker, 0 % fee, non-custodial
mode on mainnet with roughly a dozen miners. No mainnet block has been
found yet — the pool is young, and this line gets updated when that
changes, not before.
Shipped since Phase 32
These landed after the phase log below was written; they are done, not planned:
- Per-worker coinbase (Model A, non-custodial) — every miner mines to
its own address taken from the Stratum login; full reward, one output,
no fee, invalid address is refused.
[mining].payout_mode = "per_worker". Verified end-to-end against Bitcoin Core regtest (block accepted, correct attribution, witness commitment, no cross-pay). Design doc:docs/design/per-worker-coinbase.md; operator guide: Public 0% Solo Pool. - Sv2 modernisation to the 2024 spec — secp256k1 + EllSwift + Schnorr NOISE-NX; live-verified against Bitaxe / NerdQAxe hardware.
- Bitcoin Core IPC template source — opt-in
[node].template_source, Cap’n Proto against Core ≥ 31, GBT+ZMQ stays the default (v1.16.0). - Setup wizard with i18n in 8 languages — plus a locale dropdown; the whole UI is i18n-guarded in CI (no hardcoded strings).
- Donation support — configurable donation addresses, admin surface at
/admin/donations(v1.19.0). - Outbound webhooks + read-only API token scopes — see Add-ons & Sidecars (v1.7.0).
- Backup section — export/restore of the SQLite database from the admin UI.
- Umbrel delivery — community store plus the official
umbrel-appssubmission. - Scrolling banner — operator-editable marquee announcement bar,
admin surface at
/admin/banner(v1.21.0).
Known open items
- Sv2 Job Negotiation / Job Declaration — not implemented. Only the Sv2 Mining Protocol ships; miners cannot select their own transaction set. Not currently scheduled.
- Windows
.msi— paused. There has been no Windows build runner since the forge move, so the release pipeline skips the job. The WiX sources (packaging/windows/) stay maintained and the artifact returns when a runner exists. - macOS
.dmgin CI — likewise no macOS runner; the.dmgis built by hand per release viapackaging/macos/build-macos-release.sh. - Phase B signing — macOS notarization (Developer ID) and Windows code-signing (EV certificate) are both still open.
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/events→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 web-push shipped in Phase 21 (pure-Rust backend —crates/notifier/src/webpush.rs+ the daemonpush_send_loop) - 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 (per-arch aarch64 + x86_64, unsigned — notarization is Phase B; not built in CI — there is no macOS runner since the forge move, so each release’s
.dmgis built by hand viapackaging/macos/build-macos-release.shand attached after publish) - Windows .msi (unsigned — code-signing is Phase B; not currently built — no Windows runner since the forge move, WiX sources maintained)
- 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_255<B032>merkle_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 (withSEQ0_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 — shipped via Option B in v1.16.0 (see UPDATE at the end); the decision history that led there is preserved below. 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. Re-eval 2026-06-12 (v1.0.23): trigger NOT met. Core 31.0 again broke themining.capnpschema (clients built against older schemas fail hard onInit.makeMining;createNewBlock/checkBlockgained a requiredcontextparameter,getCoinbaseTxchanged shape) — the IPC interface is still iterating with breaking changes per release, the opposite of stable. The SRI side matured considerably (1.0 → 1.6, split intostratumlibs +sv2-appsbinaries/Docker with a production-ready release policy, new SV2-only solo mode), but its template-provider path sits on top of the same moving Core schema. Decision: stay on Option C; next re-eval at Core 32 — if the schema freezes there, Option A/B become concrete. UPDATE (v1.16.0, 2026-07-09): Option B shipped. An opt-inwarppool-bitcoin-ipctemplate source (Cap’n Proto against mainline Core >= 31) now feeds the samejob_refresh_loop/JobBuilder path through a sharedTemplateSourcetrait; GBT+ZMQ stays the default. Measured template latency ~5–12 ms vs ~90–100 ms for GBT+ZMQ (see benchmarks +docs/design/mining-ipc-backend.md). The Sv2 TDP path (Option A / the Phase 7.6a wire messages) remains deferred — the IPC backend talks capnp to Core directly and does not use the TDP messages, so 7.6a stays orphaned for this path. The schema-churn caveat is now a managed maintenance cost (schemas vendored + pinned per Core major).
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— resolved in v1.12.0: Sv2 channels now run the sameVarDiffengine as V1 (crates/stratum-v2/src/state.rs), including the grace-window ring buffer added in v1.14.1. The channel target is no longer pinned at diff-1.
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 (small), 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, git.warppool.org/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 — shipped. Started in Phase 6 as keyless OIDC
cosign sign-blob; since the forge move every release is signed with the project cosign key (cosign.pubin the repo root,SHA256SUMS.bundle). Keyless-OIDC artifacts exist only for releases ≤ v1.15.0. - SBOM (Syft) — shipped,
sbom.spdx.jsonin every release viaanchore/sbom-action. - 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.