Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Performance — Rust async (Tokio), pure-Rust ZMQ + NOISE (no libzmq / OpenSSL required), reproducible release builds
  2. Miner compatibility — Nerdminer V2, Bitaxe (AxeOS), Avalon Q, Antminer S21/S23 Pro, Whatsminer (MicroBT), NerdNOS/Octaxe
  3. OS compatibility — macOS (Intel + Apple Silicon), Windows, Linux x86_64 + aarch64 (deb/rpm/AppImage), Raspberry Pi 5, Umbrel (Docker)
  4. Security — memory safety, TLS, JWT + 2FA-TOTP + API tokens, audit log, signed releases (cosign), reproducible builds, auto-update with on-failure rollback
  5. Stratum V1 + V2 — plain + TLS for V1, NOISE-NX for V2, V1↔V2 translator as a sidecar
  6. Admin profiles — Small / Medium / Large / Enterprise (hot-switch)
  7. 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
  8. 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.

ComponentStatus
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

ChapterContents
Getting StartedQuick install + first connection
Configuration Referenceconfig.toml + secrets.toml + env vars
Setup Health-ChecksFirst-run wizard + daemon periodic check
Notificationsntfy / Telegram / Discord / Slack / Email setup
ObservabilityPrometheus metrics reference + Grafana + alerts
TroubleshootingSymptom → diagnosis → fix
Packaging & DeploymentDocker, .deb/.rpm/.AppImage/.dmg/.msi, RPi 5, Umbrel
SecurityThreat model, auth stack, key material, audit log
Reproducible Buildslto=fat + SOURCE_DATE_EPOCH + verify script
Auto-UpdateUpdate loop + Cosign + rollback

Architecture

ChapterContents
System ArchitectureCrate layout, daemon tasks, storage schema, Sv2 stack
UI DesignWarp-drive concept + implementation status
TestingUnit / integration / sim / regtest
Performance BenchmarksCriterion suites + baseline numbers

Sv2 Stack

ChapterContents
Phase HistorySv2 chronologically (phase 7.1 → 7.6a)
RoadmapAll phases + what’s still open
  • 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:

  1. Docker — multi-arch image, runs on Umbrel-Box, NAS, or any Linux/macOS host
  2. Native Binary.deb / .rpm / .dmg / .msi / .AppImage for bare-metal hosts and Raspberry Pi 5
  3. 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) — if stratum_tls_listen + cert/key are set in config.toml
  • V2 NOISE-NX (:34254) — if sv2_listen is set. Requires sv2_authority_priv_key_hex in secrets.toml; generate it with dvb-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

  1. Configure your miner with stratum+tcp://<pool-host>:3333 as the pool
  2. Use your BTC receive address as the worker name (e.g. bc1q...)
  3. Open the pool UI at http://<pool-host>:18334
  4. The pool hashrate chart shows shares live; the /blocks tab 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

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 master 96a9202 (2026-06-05, one commit past 95565ee, which the Umbrel app pins), and mkpool master d2e4819 (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-pool 96a9202, mkpool 4785cd5): none of the three has any signed-message ownership mechanism.
dvb-WarpPoolckpool (solo)public-poolmkpool
CoinsBitcoinBitcoinBitcoin9 chains (BTC, BCH, BitcoinII, DigiByte, eCash, ZEC, LTC, DOGE, BCH II; DOGE also merge-mined under LTC)
Starting difficultyhardware-aware, 7 device classes from user agent (unrecognised falls back to the profile default)42 by default, operator-configurable (startdiff), same value for every miner100,000, same value for every minerfixed per port tier (default 65,536; SV2 extended channels: seeded from the miner’s nominal hashrate)
Sub-1 difficultyfrom 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.00001no (floor 1; configured floors 1024)
Difficulty persistencesurvives reconnects and pool restartsnonono
suggest_difficulty / d=both, clamped to the device class; a repeat or below-current suggest is deduped and treated as a seed, not continuous controlsuggest only (no d= password syntax); clamped up to mindiff, and a repeat or already-current suggest is dedupedsuggest unclamped, one-shot (latched); d= integer, upward onlyboth; 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 styleEMA + Poisson significance gate (retarget only when the share-rate clears a 1/√N confidence band) + guarded idle decay, per-class floorsdual EMA (60 s / 300 s window) + fixed 0.15–0.4 share-rate hysteresis band, retarget gated on ≥72 shares or 240 s60 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 portsno — VarDiff adapts per devicevia separate instancesnoyes (one fixed-diff TCP port per tier)
Stratum V22024 spec, live-verified (Bitaxe / NerdQAxe)nonot in master (side branch)yes — Noise NX handshake, EllSwift/BIP324 key exchange, encrypted transport (the source names no spec version)
TLS for minersout of the box + Let’s Encrypt guidenonoyes (in-binary)
Block/coinbase standardsBIP34 height, SegWit commitment, BIP310 version-rolling (overt ASICBoost), BIP54-forward-compatible coinbase locktimeBIP34, SegWit, version-rolling, and BIP54 (nSequence fffffffe + nLockTime = height−1)BIP34, SegWit, version-rollingBIP34, SegWit, version-rolling (mask 1fffe000, off for BCH)
Payout modeltwo 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 modesolo 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/spays 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 donation1 (+ commitment), 2 with the dev fee1 (+ commitment), 2 with a donation; more on eCash (consensus-mandated outputs)
Multi-node failoveryes — sub-second switch, submitblock to all nodes in parallelyes (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 latencyZMQ from every configured node, first winsCore IPC notifications when configured, otherwise ZMQ, plus a polling backstopZMQ (optional; falls back to a 500 ms poll), which triggers a getmininginfo pollZMQ + polling fallback + watchdog
Template sourcegetblocktemplate (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+ZMQalso bothgetblocktemplate by default, with an opt-in Core IPC template path (ipcmining / ipctemplate) that falls back to RPCgetblocktemplate (RPC)getblocktemplate (RPC)
Rate-limit / auto-banper-IP on connect/auth/API + escalating ban + manual block listper-user exponential auth backoff (capped 600 s); no IP or share-rate bansconnection cap, socket timeout, user-agent filter; no per-IP limit or ban listtoken-bucket + invalid-share ban, plus a DB-backed address/worker blacklist
Web UI / APIPWA, 8 languages, authenticated, live SSEnone (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 /metricsyes (pool + per-miner + network)nonoyes (opt-in build flag)
Notifications5 sinks + Web Push; friends can subscribenoneTelegram/Discord (block found, plus a Discord restart notice)none in the open release
Address-ownership owner panelyes — 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 movedno (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 telemetryBitaxe / Antminer / Avalon / Whatsminer probes + mDNSnonono
Energy & solar trackingyesnonono
Tests1201, gating every release in CIone SHA-256 self-test; no pool-level tests63, 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
Releasessigned 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 packagingbuild from source / Docker image from CI; no tags or releasesbuild from source; tags v0.2.0 and v.0.3.0 present, plus a release workflow
Mainnet track recordnot yet — the project is youngmany (hosted, since 2014)yes (hosted)hosted instance live on mainnet
LicenseAGPLv3 + commercialGPLv3GPLv3 (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

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

  1. Why DuckDNS + Let’s Encrypt
  2. Phase 1 — Create a DuckDNS account
  3. Phase 2 — Configure router DDNS
  4. Phase 3 — Open port 3334 on the router
  5. Phase 4 — Run the setup script
  6. Phase 5 — Restart the daemon
  7. Phase 6 — Verify the certificate
  8. Phase 7 — Move your own miners over
  9. Phase 8 — Invite your friends
  10. Sleep prevention on macOS
  11. Maintenance
  12. Troubleshooting
  13. Security notes for this tier
  14. 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.org resolves 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.

  1. Open https://www.duckdns.org/ in your browser.
  2. Sign in via GitHub, Twitter, Reddit, or Google in the top right.
  3. 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
  4. Type a subdomain name (e.g. warppool-oliver or mypool-mining — anything still free) and click “add domain”.
  5. The subdomain appears in your list. Write down:
    • Subdomain (just the warppool-oliver part, no .duckdns.org)
    • Token (top right, 32 hex characters)

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

  1. Browser to http://fritz.box/ or http://192.168.178.1/.

  2. Log in with your Fritz!Box password.

  3. Left menu: Internet → Permit Access (or Freigaben).

  4. Tab DynDNS.

  5. Enable “Use DynDNS”.

  6. DynDNS provider: “Custom” (or “Benutzerdefiniert”).

  7. 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>.

  8. Domain name: <SUBDOMAIN>.duckdns.org (e.g. warppool-oliver.duckdns.org)

  9. User: anything (e.g. none) — DuckDNS only checks the token.

  10. Password: anything (e.g. none).

  11. 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

  1. Internet → Permit Access → Port Forwarding (Portfreigaben).
  2. Click “Add Device for Sharing” (or edit an existing entry if your Mac is already there).
  3. Select your Mac (should appear as MacBook-Pro / mac-mini etc. with IP 192.168.178.10).
  4. Click “New Sharing”“Port Sharing”.
  5. Application: “Other Application”.
  6. Label: WarpPool TLS Stratum.
  7. Protocol: TCP.
  8. Port to device: 3334.
  9. Public port: 3334.
  10. Check “Enable sharing”.
  11. OKApply.

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.

  1. Open a terminal.

  2. Switch to the WarpPool folder — replace the path with wherever you cloned the repository:

    cd /path/to/dvb-WarpPool
    
  3. Start the script:

    ./scripts/setup-tls-public.sh
    
  4. 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

  5. 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
  6. 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-daemon should 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/):

  1. Browser to http://192.168.178.44/.

  2. Pool Configuration → Main Pool tab.

  3. Set fields:

    FieldValue
    Stratum Hostwarppool-oliver.duckdns.org
    Stratum Port3334
    Stratum User(unchanged: bc1q...YOUR_ADDRESS.BitForgeNano)
    Stratum Password(unchanged, e.g. x)
    TLSEnabled (Bundled CA) ← finally works
  4. Click Save.

  5. Bitaxe says “must restart this device after saving” → click Restart.

  6. Bitaxe reboots. After ~20 seconds it’s back.

  7. The Bitaxe UI should show an active pool connection again under “Status”, with shares ticking.

  8. 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 — getblocktemplate and submitblock time 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. Use pkill -f dvb-warppool-daemon or re-open the .app to 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”

  1. Router port forward gone? nc -zv warppool-oliver.duckdns.org 3334 from outside — must say “succeeded”.

  2. 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 with curl ifconfig.me).

  3. 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 -dates
    

    notAfter=... should be at least 30 days in the future.

  4. 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; V1 mining.authorize for 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

  1. Was wir hier eigentlich machen
  2. Phase 1 — DuckDNS-Account anlegen
  3. Phase 2 — Fritz!Box: DDNS einrichten
  4. Phase 3 — Fritz!Box: Port 3334 freigeben
  5. Phase 4 — Setup-Script ausführen
  6. Phase 5 — Daemon neu starten
  7. Phase 6 — Verifikation
  8. Phase 7 — Eigenen Miner umstellen
  9. Phase 8 — Freunde einladen
  10. Sleep-Schutz auf macOS
  11. Wartung
  12. Wenn was schiefgeht
  13. Security-Hinweise zu dieser Ausbaustufe
  14. 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.org zeigt 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.

  1. Browser auf https://www.duckdns.org/ öffnen.
  2. Oben rechts mit GitHub, Twitter, Reddit oder Google einloggen (am einfachsten GitHub falls du dort schon einen Account hast).
  3. 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.
  4. Wunschname eintippen (z.B. warppool-oliver oder oliver-pool — was dir gefällt, muss noch frei sein) → “add domain” klicken.
  5. Die Subdomain erscheint in der Liste. Notiere dir:
    • Subdomain (nur das warppool-oliver-Teil, ohne .duckdns.org)
    • Token (oben rechts, 32 Hex-Zeichen)

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.

  1. Browser auf http://fritz.box/ oder http://192.168.178.1/.

  2. Einloggen mit deinem Fritz!Box-Passwort.

  3. Linkes Menü: Internet → Freigaben.

  4. Reiter DynDNS wählen.

  5. Häkchen bei “DynDNS benutzen” setzen.

  6. DynDNS-Anbieter: “Benutzerdefiniert” wählen.

  7. 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.

  8. Domainname: <DEINE_SUBDOMAIN>.duckdns.org (z.B. warppool-oliver.duckdns.org).

  9. Benutzername: irgendwas Beliebiges (z.B. none) — DuckDNS prüft nur den Token.

  10. Kennwort: auch irgendwas (z.B. none).

  11. “Ü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).

  1. Fritz!Box → Internet → Freigaben → Reiter Portfreigaben.
  2. Klicke “Gerät für Freigaben hinzufügen” (oder bei einem bereits vorhandenen Gerät auf “Bearbeiten”).
  3. Wähle dein Mac-Gerät aus (sollte als hostname-irgendwas oder MacBook-Pro etc. erscheinen — IP 192.168.178.10).
  4. Klicke “Neue Freigabe”“Portfreigabe”.
  5. Anwendung: “Andere Anwendung”.
  6. Bezeichnung: WarpPool TLS Stratum (für deine Übersicht).
  7. Protokoll: TCP.
  8. Port an Gerät: 3334
  9. Port extern gewünscht: 3334
  10. Häkchen bei “Freigabe aktivieren”.
  11. “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.

  1. Terminal öffnen.

  2. In den WarpPool-Ordner wechseln — den Pfad durch dein eigenes Clone- Verzeichnis ersetzen:

    cd /pfad/zu/dvb-WarpPool
    
  3. Script starten:

    ./scripts/setup-tls-public.sh
    
  4. 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

  5. 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
  6. 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-daemon sollte 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):

  1. Im Browser zu http://192.168.178.44/ (oder wie auch immer dein Bitaxe im LAN heißt).

  2. Pool Configuration → Main Pool Reiter.

  3. Felder so setzen:

    FeldWert
    Stratum Hostwarppool-oliver.duckdns.org
    Stratum Port3334
    Stratum User(bleibt: bc1q...DEINE_ADRESSE.BitForgeNano)
    Stratum Password(bleibt was es war, z.B. x)
    TLSEnabled (Bundled CA) ← jetzt korrekt
  4. Save klicken.

  5. Bitaxe meldet “must restart this device after saving” → Restart klicken.

  6. Bitaxe rebootet. Nach ~20 Sek. ist er zurück.

  7. Im Bitaxe-UI sollte unter “Status” wieder eine aktive Pool-Verbindung stehen + Shares ticken.

  8. 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 — getblocktemplate und submitblock timen 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 via pkill -f dvb-warppool-daemon oder die .app neu ö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”

  1. Fritz!Box Port-Forward weg? nc -zv warppool-oliver.duckdns.org 3334 von extern → muss “succeeded” sagen.

  2. DuckDNS resolved nicht oder auf falsche IP? dig +short warppool-oliver.duckdns.org @8.8.8.8 → muss deine aktuelle Public-IP zeigen (vergleiche mit curl ifconfig.me).

  3. 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 -dates
    

    notAfter=... sollte mindestens 30 Tage in der Zukunft sein.

  4. 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

  1. Turn on per-worker mode
  2. What miners enter
  3. Public hardening profile
  4. TLS
  5. Behind a reverse proxy
  6. 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 writes payout_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:

  1. The page hands out a short one-time challenge message.
  2. 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.
  3. 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.

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=1 on. 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 when peers.inbound = 0 on 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 = true the daemon trusts X-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 no peers.inbound = 0 warning.
  • 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 = true with a keep count you’re happy with.
  • [ratelimit] enabled = true with autoban_enabled and reject_ban_enabled on.
  • max_connections_per_ip set (unless miners sit behind one NAT).
  • TLS in front of Stratum (own cert for public-CA-only firmware).
  • Reverse proxy: trust_proxy_headers + cookie_secure set 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

TierUse caseOSS sufficient?Profile
SoloOne operator, own miners on LANYesklein (0-5 miners)
Solo + Friends5-20 miners, trust-based, LAN or InternetYesmittel (6-20)
Community / public per-worker pool20-100 miners, vetted or openly non-custodialYesgross (21-100)
Custodial / reward-sharing servicePublicly advertised pool that holds funds or splits rewardsNo — requires commercial stackenterprise (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 enterprise just 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, :3336 in 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, with submitblock broadcast 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 admin or read-only read scope
  • 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:

  1. A DNS hostname (e.g. via DuckDNS) pointing at your public IP
  2. A Let’s Encrypt certificate (the Bitaxe “Bundled CA” mode requires a CA that’s in the Mozilla root bundle)
  3. 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.

ItemStatus
Per-IP connection capShipped (v1.13.0) — [stratum] max_connections_per_ip
Connect/auth rate-limit + escalating auto-banShipped — [ratelimit] with autoban_*
Reject-ratio ban (share-flood defence)Shipped (v1.13.0) — [ratelimit] reject_ban_*
Subnet access controlShipped — [ratelimit] ban_cidrs / allow_only / allow_cidrs
Stratum whitelist / registrationShipped 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 gatingShipped — [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 backupShipped (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-logOperator-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)

GapWhy it matters at this scale
DDoS mitigationCloudflare TCP pass-through or dedicated providers; a connection cap alone is not protection
PPLNS / FPPS payout engineOSS 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 accessA single admin user, plus read-only API tokens. Full per-user roles and per-user audit are not in OSS
T&C / Privacy templatesEU 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 trackingDaemon panics go to the tracing log only
SLA / maintenance contract24/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].

AreaSpecialist
Pool T&C templateLawyer
Privacy policy (GDPR)Lawyer
AML / KYC if pool fees applyTax advisor + possibly BaFin / FCA / equivalent
Income taxation of pool feesTax 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.toml key — see Announcement banner.

[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 = false hides the browsable Workers/wallet directory (/api/wallets and its nav link) from anonymous visitors — like solo.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 = false masks 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-wide payout_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 mode payout_address is ignored and may be empty, and pool_fee_percent / operator_donation_percent must be 0 (the full reward goes to the finder). A miner whose login is not a valid address for this network is rejected. See docs/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)getblocktemplate over RPC, refreshed on ZMQ block events plus the job_refresh_secs poll. 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 via waitNext instead 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.
  • submitblock goes to ALL nodes in parallel — the block moment is the one instant where a dead node can cost real money; any Accepted wins.
  • 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 to on_rpc_down); rpc-down itself 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:

BadgeMeaningValues
liveapplies immediately, no restartconnection cap, VarDiff range, miner-poll interval, retention TTLs, Stratum-auth & API rate limits
restarttakes effect on the next daemon startdb_pool_size (SQLite pools can’t resize live)
guideadvisory only — a switch does not change itstratum_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.

SettingScopeSmallMediumLargeEnterprise
Recommended miners≤5≤20≤100>100
DeploymentSoloSolo + friendsCommunity¹Service¹
Connection caplive16642564,096
RAM targetguide128 MB512 MB2 GB8 GB
Stratum threadsguide12all coresall cores
DB poolrestart481632
UI refreshguide5 s2 s1 s1 s
Miner pollinglive30 s15 s5 s1 s
VarDiff initiallive4,09632,7681,048,57616,777,216
VarDiff minlive1,0244,09665,536524,288
VarDiff maxlive262,1444,194,30467,108,8641,073,741,824
Retention — raw shareslive1 d7 d30 d90 d
Retention — 5-min agglive7 d30 d90 d1 y
Retention — 1-h aggguide90 d1 y5 y10 y
Retention — miner telemetrylive1 d7 d30 d90 d
Stratum auth / minlive601206006,000
API / minlive1206003,00060,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 of min.
  • 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 sets d immediately, 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 after mining.authorize.
  • d=<x> in the authorize password field (e.g. password x,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"]
FieldRequiredDescription
urlyesTarget URL. Only http:// / https:// are accepted.
secret_envnoName of the env var holding the HMAC secret (never inline). When set, every request carries X-WarpPool-Signature: sha256=<hex>.
eventsnoList of event type strings to deliver. Empty → default set (low-frequency only).
enablednoDefault 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 eventsnew_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.

EndpointAuthDoes
GET /api/admin/banneradminReturns the stored text, expires_at and a computed active flag.
POST /api/admin/banneradminStarts an announcement. Body: {"text": "...", "duration_secs": 3600}.
DELETE /api/admin/banneradminEnds it early. Clearing an already-empty banner is a no-op, not an error.

POST validation — all violations return 400:

RuleLimit
text non-empty after trimmingrequired
text length200 characters
text control charactersrejected (they would break the marquee)
duration_secs60 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:

KeyValue
banner_textthe announcement text (empty ⇒ no banner)
banner_expires_atRFC3339 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

VarDefaultDescription
WARPPOOL_HEALTH_CHECK_INTERVAL_SECONDS60Interval 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

VarDefaultDescription
WARPPOOL_AUTOUPDATE_REPO(unset)e.g. dvb-projekt/dvb-WarpPool. When set, /api/admin/update is active.
WARPPOOL_AUTOUPDATE_INTERVAL_HOURS24How 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

VarDefaultDescription
WARPPOOL_DISCONNECT_DEBOUNCE_SECS30Per-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)

VarDefaultDescription
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)

VarDefaultDescription
WARPPOOL_ANOMALY_CHECK_INTERVAL_SECS300Interval of the periodic anomaly detection. 0 = off.
WARPPOOL_ANOMALY_DEBOUNCE_SECS1800Per-(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_TOKENnotifier.telegram.bot_token_env
  • DISCORD_WEBHOOK_URLnotifier.discord.webhook_url_env
  • SLACK_WEBHOOK_URLnotifier.slack.webhook_url_env
  • POOL_SMTP_PASSWORDnotifier.email.password_env

Test/Debug

VarDefaultDescription
RUST_LOGinfoStandard 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.

VarDefaultDescription
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/bitcoinWhere 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.pyPath to the Anker bridge script.
DVB_WARPPOOL_ANKER_REPO/opt/dvb-warppool/anker-solix-apiCheckout 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 the live / restart / guide table 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 to secrets.toml
  • Admin tokens (POST /api/admin/tokens) — effective immediately
  • 2FA (POST /api/auth/2fa/enable) — effective immediately

See Also

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:

  1. Bitcoin Core health — everything reachable over RPC (/api/bitcoin-health)
  2. 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

CheckWhat it doesPrivacy
Bitcoin health (RPC calls)Localhost → Bitcoin Core✅ Local
Connectivity snapshotTCP-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:

RPCWhat we extract
getnetworkinfoversion, subversion
getblockchaininfoibd, chain, blocks, headers, verification_progress, size_on_disk, pruned
getindexinfotxindex (on/off)
getpeerinfoinbound/outbound peer count
getzmqnotificationspubhashblock/pubrawblock endpoints

Plus automatic warnings:

ConditionWarning
ibd=trueMining not yet possible; the pool should only start after sync
peers.total < 8Tip latency increased; new blocks arrive later
peers.inbound = 0 on mainnetPort 8333 is probably not forwarded
zmq.hashblock=NonePool falls back to RPC polling (job refresh latency)
pruned=trueSome features (txindex-based) are limited
verification_progress < 99.9% (and not IBD)Still syncing

Why inbound peers matter for a pool. peers.inbound = 0 is 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 keep listen=1 on. 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:

  1. Reads the RPC + ZMQ settings — from your bitcoin.conf if a path is given, otherwise mainnet defaults (:8332) — and pre-fills the form.
  2. 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.
  3. When no bitcoin.conf is 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 to warppool_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 findingSnippet line
zmq.hashblock=Nonezmqpubhashblock=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 < 8maxconnections=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 true
  • bind= — the default all-interfaces fits 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) — via navigator.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 return add_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

  1. UI field “bitcoin.conf path” (optional, OS-aware placeholder)
  2. Path is sent along with the /api/bitcoin-health POST
  3. 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)
  4. 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:28333 and listen=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 suggest maxconnections=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:

  1. Re-resolve RPC auth from config.node.rpc_cookie_path (preferred) or secrets.rpc_user/rpc_pass (the cookie gets rotated on Bitcoin Core restart — we read fresh every time)
  2. warppool_health::check_bitcoin_health(&client, &url, &auth).await
  3. collect_bitcoin_warnings(&h)
  4. 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, /metrics covers 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

  • Notificationsrpc-down / rpc-recovered events are fired by the same health loop
  • Observabilitywarppool_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:

  1. rpc_cookie_path — a local second node with its own data dir.
  2. user:pass embedded in the URL (http://user:pass@host:8332) — a remote node with its own credentials (this lives in config.toml, so mind the file permissions).
  3. Nothing — the global secrets.rpc_user / rpc_pass are 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.
  • submitblock goes 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 first Accepted wins.
  • 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-down fires 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 an rpc-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 bitcoind crashes, 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

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):

SinkProtocolConfig keySecret via env
ntfyHTTP POST[notifier.ntfy](none — public topic URL suffices)
TelegramBot API[notifier.telegram]bot_token_env
DiscordWebhook[notifier.discord]webhook_url_env
SlackIncoming Webhook[notifier.slack]webhook_url_env
EmailSMTP via lettre/TLS[notifier.email]password_env (optional)

Events

The daemon emits these event types:

EventWhenPriority
block-foundAfter a successful submitblock in the daemonhigh
miner-disconnectAn authenticated worker closes the Stratum connection; per-worker debounce 30smedium
rpc-downNo Bitcoin Core node reachable at all (with [[node.backup]] a primary outage alone no longer fires this)medium
rpc-recoveredHealth check first hits RPC=ok again after a down statelow
health-alertA setup/health check started failing (e.g. Bitcoin Core warning). Gated by on_health_alert, on by defaultmedium
node-failoverThe active Bitcoin Core node changed (failover to a backup, or return to the primary). Delivered to sinks that enabled on_rpc_downmedium
testOperator clicks the test button in the admin UIlow

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:

  1. A miner opens their public page at /users/<address>.
  2. 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.
  3. Once verified, a Notifications field appears. They paste a personal target and save it — for this address only.
Per-wallet eventFires whenTarget sees
block-foundthis address finds a blockworker, height, reward, block hash (high priority)
personal-bestthis 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-100this 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-offlineone 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.

ChannelHow friends subscribeEffort
ntfyInstall the ntfy app, subscribe to your topic name~2 min, no account
TelegramYour bot posts into a group everyone is inone-time setup
DiscordWebhook points at a channel on a shared serverone-time setup
EmailTheir addresses go into the to = [...] listtrivial

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:

  1. You (the operator) configure [notifier.ntfy] as shown below.
  2. 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.
  3. 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:

  1. Create a Telegram group with your friends.
  2. Add your bot to the group.
  3. Send any message in the group, then fetch https://api.telegram.org/bot<TOKEN>/getUpdates and look for the group’s "chat":{"id":-100…} — group ids are negative; copy the minus sign too.
  4. Put that id into chat_id and 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.
  • Emailto is 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:

  1. Restart the daemon — logs show notifier sink ready for each working sink. If an env var is missing or a required config field is empty, you’ll see notifier sink skipped with a reason; the daemon starts anyway.
  2. In the admin UI under /admin/notifications → “Server-Side Sinks (Daemon)” lists all active sinks.
  3. Per sink, a Test button → POST /api/admin/notifier/test?sink=<name> fires a test event at just that one. The badge switches to ok/err.
  4. Test all sinks button → POST /api/admin/notifier/test with 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

  1. Open the pool UI, log in
  2. Go to /admin/notifications“Enable background push” button
  3. Browser asks for permission → “Allow”
  4. Subscription is registered with the pool, from now on pushes arrive

Which events fire push

EventTagWhen
block-foundblock-foundEvery block found (requireInteraction=true → notification stays until the user acknowledges it)
healthhealthRPC down OR health snapshot with warnings
updateupdatePool 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 unsupported badge.

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": { … } }ts is an RFC3339 string (e.g. "2026-06-19T12:00:00+00:00"), not a unix number.
  • Headers: X-WarpPool-Event: <type> and, when secret_env is 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-frequency new_job / shares_accepted explicitly via events only 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

Observability

dvb-WarpPool exposes its runtime state in two complementary ways:

  1. Pull — Prometheus-compatible /metrics endpoint
  2. 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)

MetricTypeDescription
warppool_blocks_found_totalcounterAccepted blocks since the first daemon start
warppool_shares_accepted_totalcounterAccepted shares across all workers
warppool_shares_rejected_totalcounterStale / low-diff / malformed
warppool_workers_totalgaugeNumber of workers ever seen
warppool_wallets_totalgaugeDistinct wallet prefixes ever seen (lifetime)
warppool_rpc_readygauge1 if Bitcoin Core RPC is reachable
warppool_rpc_ibdgauge1 if Bitcoin Core is in initial block download
warppool_network_heightgaugeChain tip height according to our node
warppool_network_difficultygaugeCurrent network difficulty
warppool_current_job_heightgaugeHeight of the template currently being served
warppool_current_job_coinbase_value_satsgaugeCoinbase reward in sats
warppool_started_at_secondsgaugeDaemon start as a unix timestamp
warppool_last_template_at_secondsgaugeLast successful getblocktemplate
warppool_build_info{brand,profile,chain}gaugeConstant 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.

MetricTypeDescription
warppool_active_walletsgaugeDistinct wallet prefixes with an accepted share in the last 5 min
warppool_best_share_difficultygaugeHighest share difficulty ever accepted (0 if none yet)
warppool_pool_hashrate_hpsgaugePool hashrate (H/s) from the last completed 5-min bucket
warppool_network_hashrate_hpsgaugeEstimated 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).

MetricTypeDescription
warppool_workers_authorized_totalcounterCumulative mining.authorize successes (v1) + OpenChannel successes (v2)
warppool_workers_disconnected_totalcounterCumulative authenticated worker disconnects
warppool_active_connections{protocol="v1"}gaugeOpen Stratum V1 connections
warppool_active_connections{protocol="v2"}gaugeOpen Stratum V2 connections
warppool_active_connections{protocol="v1_tls"}gaugeSubset of v1 carried over TLS (plain v1 = v1v1_tls)
warppool_bitcoin_rpc_latency_secondshistogramEnd-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:

MetricTypeLabelsDescription
warppool_miner_hashrate_ghsgaugelabel, host, vendor, modelMiner-reported hashrate in GH/s
warppool_miner_temperature_cgaugelabel, host, vendor, modelASIC core temperature in °C
warppool_miner_power_wgaugelabel, host, vendor, modelPower draw in watts
warppool_miner_voltage_mvgaugelabel, host, vendor, modelASIC core voltage in mV
warppool_miner_fan_rpmgaugelabel, host, vendor, modelFan speed in RPM
warppool_miner_last_probe_age_secondsgaugelabel, host, vendorSeconds since the last successful probe
warppool_miner_probe_healthgaugelabel, host, vendor1 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:

MetricTypeDescription
warppool_notifier_sinks_activegaugeNumber of initialized sinks
warppool_notifier_events_sent_total{sink,event,result}counterSend 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).

MetricTypeDescription
warppool_manual_bansgaugeIPs on the manual (permanent) block-list
warppool_auto_bans_activegaugeCurrently 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:

EventFires when
block_foundA block was submitted to Bitcoin Core. The first push has accepted: null; a second event carries the accept/reject verdict.
new_jobA new template arrived (ZMQ, IPC push, or poll). High frequency.
shares_acceptedAggregated share counter, throttled to at most once a second. High frequency.
profile_switchedThe admin hot-switched the active profile.
health_snapshotPeriodic Bitcoin Core health re-check (RPC, peers, IBD, pruned, ZMQ, warnings).
update_availableThe periodic auto-update check found a newer release.
heartbeatServer-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_path in config.toml points 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_pass must match the values in bitcoin.conf. In secrets.toml, not config.toml — otherwise the daemon complains at startup.
  • On permission errors: bitcoind runs as a different user; the cookie is 0600 and unreadable for your warppool user. The setup wizard catches this and suggests chmod 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: getblocktemplate without -walletbroadcast=0 needs an unlocked wallet in Core. Unusual in a pool context, though — we use GBT without a wallet.
  • Too few peers: getpeerinfo shows 0 or 1. Core then refuses to mine to avoid ending up on a minority fork. Fix: wait, or use -minimumchainwork=0 for 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=1 setup 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.authorize with a bad address.
  • VarDiff too aggressive: the initial diff was too high, the miner can’t produce shares within the target_seconds_per_share window, retargets down quickly, and some firmwares don’t like rapid retargets. Fix: a higher min_diff or a lower initial_diff in [vardiff].
  • Auth rate limit kicks in: at >auths_per_sec auth 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 modeSelf-signed pool certWhat to do
No verification✓ worksnothing — connect to :3334
TLS (Custom CA certificate)✓ worksdownload 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 worksee 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:

  1. Stay on plain :3333 for 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).
  2. 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_path to the issued files and connect the miner to pool.example.com:3334. Renewal (90 days for LE) plus a daemon restart is your responsibility.
  3. 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

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:

VendormDNS discoveryTelemetry 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 namesvendor=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::migrate throws 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

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 with packaging/macos/build-macos-release.sh and attached manually (which also means SHA256SUMS gets regenerated over the full asset set and re-signed — see §8 of docs/ci-forgejo-porting.md in the repository; that note is not part of this handbook). The Windows .msi is paused.

Update paths: After the initial install via one of the formats below, you can apply updates in two ways:

  1. Package manager (apt/dnf/docker pull/Umbrel app updater) — classic, idempotent, controlled
  2. 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

FormatArchitectureStatusNotes
Dockeramd64 + arm64git.warppool.org/dvb-projekt/dvb-warppool:<tag>
.debamd64 + arm64apt/dpkg, systemd unit included
.rpmamd64 + arm64dnf/rpm, systemd unit included
.AppImagex86_64 + aarch64Portable, no root required (RPi 5)
.dmgaarch64 + x86_64⚠ unsigned, built manuallyNo macOS runner — built per release via packaging/macos/build-macos-release.sh; Phase B: Notarization (Dev-ID)
.msix64⏸ pausedNot built since the forge move (no Windows runner) — WiX sources maintained
TarballLinux + macOSManual installs
Umbrelamd64 + arm64packaging/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-warppool so the setup wizard can atomically write into it via sudo
  • systemctl 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_raw eviction 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.

  1. Open the .dmg → drag dvb-WarpPool.app into the Applications folder
  2. Right-click → “Open” → confirm “Open” (Gatekeeper override)
  3. The first launch opens a Terminal with the setup wizard
  4. 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 with cargo wix on a Windows machine, or run the pool via Docker or the Linux packages. The steps below apply to an .msi you built yourself.

Unsigned — SmartScreen warning on first launch. Code-signing with an EV certificate is planned for Phase B.

  1. Double-click the .msi → “More info” → “Run anyway”
  2. Installs to C:\Program Files\dvb-WarpPool\
  3. Start the setup wizard manually: Start menu → dvb-warppool-setup
  4. 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 reads secrets.toml, but it must be able to read it. Leave the file owned by that uid with mode 600; a root-owned 600 file 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 submit with a Developer ID certificate + Apple ID + app-specific password. Since the .dmg is 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_ID and APPLE_NOTARY_PASSWORD would 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:

  1. Built-in TLS — set status_tls_listen (and provide a cert) and the daemon serves HTTPS directly. Simplest; no extra software. See Configuration Reference.
  2. 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 own sv2_listen port (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 nginx stream {} or HAProxy TCP mode), never through an HTTP server {} / reverse_proxy block.

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 = true behind a proxy you control. If the daemon is reachable directly from the internet with this on, any client can forge X-Forwarded-For and spoof its IP into your audit log and past your IP bans. Bind status_listen to 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 = true and cookie_secure = true in [server].
  • /api/events is proxied without buffering (Caddy flush_interval -1 / nginx proxy_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 (:3333 plain, :3334 TLS, sv2_listen e.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:

ActorTrustThreat
Pool operatorhighHas shell access, can modify config and secrets. Threat: secrets leak via backup, swap file, or a compromised SSH account
Admin usermediumLogs in via browser, can manage profile, tokens, and auto-update. Threat: compromised browser cookie or leaked API token
MinerlowAuthenticates only via Bitcoin address as username. Threat: stale-share flood, reconnect loop, malformed Stratum, ASIC-Boost abuse
InternethostileUnauthenticated. Threat: DoS flood, TLS downgrade, reverse-proxy IP spoofing, supply chain via auto-update

Threat → Mitigation Matrix

ThreatMitigationPhase
Stratum DoS floodRate 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 exhaustionPer-IP concurrent-connection cap ([stratum].max_connections_per_ip, default 0 = unlimited) — one host can’t monopolise the global connection pool. Loopback exempt33
Unwanted / hostile network rangesCIDR 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 disabled33
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 disabled33
Unauthorized payout address miningOptional 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 bruteforceAuth-RPM limit per IP — on exceedance, mining.authorize is acknowledged with result: false (no disconnect loop), Argon2id for admin password3
TLS downgradeRustls 0.23 with default suite set (TLS 1.2 + 1.3, no TLS 1.0/1.1)3
RPC credential leakCookie auth preferred (auto-rotation on bitcoind restart), otherwise secrets.toml chmod 6001
Supply chaincargo deny in CI with license allowlist + advisories, signed releases (project cosign key; keyless OIDC only ≤ v1.15.0), reproducible builds6, 8b, 8g
Memory safetyRust with no unsafe except in NOISE/ZMQ bindings that are wrapped by upstream crates
Stratum replayDedup per (worker, job_id, extranonce2, ntime, nonce) tuple in share validator2
Reorg attackBlock-found events only after submitblock OK, UI marks pending/accepted/rejected separately2
Insecure UI originCORS permissive only in dev mode; production runs daemon-static-served UI = same-origin → no CORS issue5
Admin session hijackJWT 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 theftToken hash (sha256) instead of plaintext in DB, soft-revoke via revoked_at, last_used_at tracking for audit3.2
Account takeoverOptional 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 hatch3.3
Audit bypassAll state-changing routes emit audit_log (actor, action, target, peer_ip, ok, details)3
IP spoofing via X-Forwarded-ForDefault trust_proxy_headers = false; only enable when behind a trusted proxy2.5
Notifier secrets in backupToken/webhook URL/SMTP password are all env-var references — config.toml contains no secrets15
Auto-update tamperingMandatory sha256 verify; optional cosign-verify-blob (env-gated) before atomic_swap; OnFailure rollback via systemd8c, 8f, 8g
Privilege escalation via setupUPnP whitelist on 4 ports + consent gate, external probes opt-in per probe with consent9, 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 found32
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 switch32
Sv2 coinbase manipulationMiner extranonce length is checked against the negotiated channel size (invalid-extranonce-size, usize comparison, no truncation)32
Web Push SSRFis_safe_push_endpoint (https + block private/loopback/link-local/CGNAT/IPv6-ULA/localhost) on subscribe AND send + DNS resolve guard against rebinding32
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.toml32

Auth Stack

dvb-WarpPool has three orthogonal auth paths:

  1. JWT cookie — browser UI, normal login flow
  2. API tokens — bearer token for scripts (wpat_<32hex> prefix)
  3. 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

KeyWhereProtectionWhat happens on leak
Pool payout_addressconfig.tomlno protection needed — it is publicNothing — incoming rewards always land at the address
Bitcoin RPC cookie~/.bitcoin/.cookiechmod 0640 (bitcoind default)Full RPC access to Bitcoin Core. Pool operator and Bitcoin user should be in the same group.
rpc_user / rpc_passsecrets.tomlchmod 0600Full RPC access. Bitcoin Core uses rpcauth= PHC hashes — leaking the hash source renders the pool password useless.
admin_password_hashsecrets.tomlchmod 0600Argon2id 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_secretsecrets.tomlchmod 0600On 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_hexsecrets.tomlchmod 0600With 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 + keyoperator-configurableoperator responsibilityIf cert/key/CSR process is leaked, MITM on Stratum-TLS is possible
2FA-TOTP secretadmin_2fa DBDB chmod 0600Per user, base32. On DB leak + admin-pw hash + jwt-secret the attacker can generate 2FA codes — all three must be protected.
Notifier tokensenv vars, NEVER config.tomlsystemd EnvironmentFile chmod 0600Telegram/Discord/Slack spam from the operator’s identity, email sending. Nothing pool-internal.
Web Push VAPIDsecrets.toml (vapid_private_key)chmod 0600With 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:

ActionTrigger
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 → field sv2_authority_pubkey_b58 (Base58, unauthenticated; the public key is by design not a secret).
  • CLI: dvb-warppool-cli sv2-pubkey reads secrets.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:

LimitValueWhen it triggers
connects_per_sec5.0TCP accept
connect_burst20Burst window
auths_per_sec1.0mining.authorize attempts
auth_burst10Burst 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:

KeyDefaultfalse means
wallet_directory_publictrueThe public wallet directory (/api/wallets) becomes operator-only
leaderboard_addresses_publictruePayout 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

BehaviourDefaultWhy
trust_proxy_headersfalseA direct-to-Internet setup must not be IP-spoofable
External probes (Phase 9)opt-in per probe + consentAvoid privacy leak — the IP goes to api.ipify.org / bitnodes.io
UPnP forward (Phase 11)consent gate + port whitelistOtherwise UPnP would become a generic port opener
Auto-update via /api/admin/updatenot periodicThe operator decides when to update — the periodic check (Phase 8e) only pushes an SSE banner
WARPPOOL_AUTOUPDATE_REPOnot setAuto-update disabled by default, operator must enable explicitly
Cosign verifyfalse in update requestOperator 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

ComponentRecommended UIDReason
dvb-warppool-daemonnon-root (warppool UID 1000)No wallet key, no privileged port
dvb-warppool-setupoperator userFirst-run wizard, writes config.toml into the operator’s home
dvb-warppool-translatornon-rootPure sidecar, no file state
rollback.sh (systemd OnFailure)rootMust 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 possibleWhy not yet
WAF / anomaly detectionOut of scope for the pool daemon. Put Caddy/Cloudflare/NGINX-WAF in front if needed
Per-worker encryption-at-rest for VarDiff snapshotsDB chmod 600 is sufficient for the threat model; key derivation would add complexity without clear value
Hardware-wallet signing for coinbaseSolo pool — coinbase goes via payout_address, the pool never touches private keys
OAuth/OIDC for adminSingle-user self-hosted is the primary target; standalone auth suffices. PRs welcome for multi-user setups
TPM attestationReproducible builds + cosign cover the audit trail; TPM would be for enterprise compliance, not solo

Accepted residual risks after the security audit (Phase 32)

Residual riskWhy accepted / condition
Miner IP / topology disclosureLargely 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 TOCTOUThe 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 DebugLatent 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-main auto-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:

  1. determines the latest release tag (or an explicitly passed tag)
  2. sets SOURCE_DATE_EPOCH from the commit timestamp
  3. locally runs cargo build --release -p dvb-warppool-daemon --locked with the path-remapping flags
  4. downloads the published release asset
  5. 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.org release 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 .dmg and Windows .msi are 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 .msi is 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

  1. Local HEAD vs tag commit:
    git rev-parse HEAD
    git rev-parse refs/tags/<TAG>
    git checkout <TAG>  # if different
    
  2. Match the Rust toolchain:
    rustup show active-toolchain
    # should match rust-toolchain.toml
    
  3. Check RUSTFLAGS:
    echo "$RUSTFLAGS"
    # must contain the three --remap-path-prefix entries
    
  4. Glibc version:
    ldd --version
    # CI uses Ubuntu LTS — a minor mismatch is OK, a major one breaks it
    
  5. If everything matches but drift persists: please file an issue with both sha256s and the ldd --version output.

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).

#!/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 via Ord. Pre-release < stable (semver convention).
  • release — forge release API (GitHub-compatible JSON schema) with minimal serde deserialization (no octocrab — that would be 30+ transitive deps for 3 fields).
  • downloaddownload_verified(client, url, dest, expected_sha) with streaming write + SHA-256 accumulator. Mismatch → file deleted.
  • swapatomic_swap(new, current, backup_to) with POSIX rename (atomic on the same filesystem). Sets chmod 0755 on the new binary, optionally backs up the old one to backup_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

  1. Daemon starts with the new binary.
  2. Crash within 5s → systemd Restart=on-failure retries.
  3. After 4 crashes in 5 minutes → systemd gives up and triggers OnFailure=dvb-warppool-rollback.service.
  4. rollback.sh:
    • Finds /var/lib/dvb-warppool/backup/daemon.* (youngest mtime wins)
    • Atomically installs it to /usr/bin/dvb-warppool-daemon (with install -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
  5. Daemon now starts with the old binary — back online.
  6. 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:

  1. Server-side env-var WARPPOOL_COSIGN_BIN=/usr/local/bin/cosign set (typically in the systemd unit’s Environment="...").
  2. 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

LayerWhat is checked
sha256File integrity against the hash supplied by the operator
cosign verify-blobSignature authenticity against the project cosign key (cosign.pub)
cosign_argsFully operator-controlled — the server only appends the downloaded SHA256SUMS file as the last (blob) arg
Audit trailupdate.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

SubWhat
8amdBook Documentation Site
8bReproducible Builds (lto=fat + –remap-path-prefix + repro-CI)
8cAuto-Update Foundation Crate + CLI
8dAuto-Update API (GET update-check + POST update)
8ePeriodic Auto-Check + UpdateAvailable SSE event
8fSystemd OnFailure-Rollback (StartLimitBurst + Hook + rollback.sh)
8gCosign-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:

PlatformAssetBuilt by
Linux x86_64 / aarch64.tar.gz, .deb, .rpm, .AppImageCI, every release
Docker (amd64 + arm64)image on git.warppool.orgCI, every release
macOS Intel / Apple Silicon.tar.gz, .dmgbuilt manually on a Mac and attached to the release afterwards
Windows x86_64.msipaused — 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 binaries job 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, SHA256SUMS and SHA256SUMS.bundle are 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.msi host mapping and the WiX packaging sources (packaging/windows/, cargo-wix) are kept maintained, and the .msi returns as soon as a Windows runner is registered. Until then, Windows users build from source.
  • macOS GUI installs go through the .dmg 1-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

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:

  1. Read APIGET /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 = false masks payout addresses in /api/workers, leaderboard_addresses_public = false masks them in the /api/hashrate leaderboard. 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.
  2. Live events (pull)GET /api/events is a Server-Sent-Events stream. Event types: block_found, new_job, shares_accepted, health_snapshot, profile_switched, update_available (plus heartbeat). Subscribe instead of polling when you need to react in real time (e.g. a billing or notification add-on reacting to block_found). The stream also emits transport-level events your client should tolerate: hello on connect and lagged (with a count) when a slow consumer dropped out of the broadcast buffer — on lagged, refetch a snapshot instead of assuming continuity.
  3. Live events (push)outbound webhooks. Configure one or more [[webhooks]] and the pool POSTs 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]].
  4. Inbound data — the generic_http provider 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.
  5. 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)
DirectionPool → you (pool initiates the POST)You → pool (you hold the stream open)
Your side runsan HTTP server (endpoint)an HTTP client (long-lived GET)
Best forserverless / no-code / cloud functions; rare low-frequency eventsalways-on local sidecars; high-frequency / every event
If your side is downevent is dropped after retries (no buffer)you simply reconnect and resume
Auth modeloptional HMAC signature (secret_env)API token header (or none for public reads)
High-frequency eventsopt-in; mind the POST-storm warningfine — 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 mutating POST/PUT/DELETE.
  • readGET/HEAD/OPTIONS only. Any mutating request is rejected with 403 Forbidden. A handful of especially sensitive operator endpoints (e.g. the Bitcoin-Core admin routes) reject read tokens outright, even on GET — those need an admin token 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/events SSE 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 banner object (announcement marquee, set through POST /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 against current if 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)

CrateWhat
warppool-profilesadmin 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-configTOML schema (PoolConfig + sub-configs + secrets)
warppool-hwdetecthardware detection via sysinfo → profile recommendation

Core mining layer

CrateWhat
warppool-bitcoin-rpcJSON-RPC + ZMQ subscribe for Bitcoin Core
warppool-bitcoin-ipcopt-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-buildercoinbase tx (BIP34 height, SegWit commitment, BIP54-forward-compatible locktime) + merkle tree + Stratum job construction
warppool-share-validatorshare PoW check + block-found detection
warppool-stratum-v1TCP listener, session state machine, VarDiff
warppool-stratum-v2NOISE handshake, binary framing, mining subprotocol
warppool-translatorV1↔V2 sidecar (a pool can offer Sv2 only; V1 miners connect via the translator)

Storage + API

CrateWhat
warppool-storageSQLite via sqlx, all tables + migrations
warppool-apiaxum HTTP API (REST + SSE stream + static UI serving)

Operational subsystems

CrateWhat
warppool-healthBitcoin Core multi-RPC health + bitcoin.conf parser + snippet generator
warppool-autoupdateversion parser + forge release client + atomic_swap + cosign hook
warppool-notifierpush sinks (ntfy/Telegram/Discord/Slack/email) + counter metrics
warppool-telemetryvendor API probes (AxeOS, NerdNOS, Avalon, …) + mDNS discovery + HTTP sweep + PoolMetrics
warppool-uninstallcross-platform uninstall plans (per-OS path tables, marker-gated Bitcoin Core targets)

Tools

CrateWhat
warppool-simulatorsim miners (vendor personas) + sim node + scenarios

Binaries

BinaryPurpose
dvb-warppool-daemonthe pool — orchestrates all subsystems
dvb-warppool-clioperator tools (hash-password, token-create, set-profile, check-update, …)
dvb-warppool-setupfirst-run wizard (axum, embedded HTML)
dvb-warppool-translatorV1→V2 sidecar (clap CLI, can run as a systemd service)
dvb-warppool-simsimulation runner (scenario list / run)
dvb-warppool-mac-launchermacOS 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:

HandleTypeUsed by
notifier: Arc<Notifier>shared via cloneblock_submit_loop, health_check_loop, periodic_update_check, NotifierConnectionSink, API
pool_metrics: Arc<PoolMetrics>atomic countersNotifierConnectionSink, BitcoinRpc, API /metrics handler
event_bus: Arc<PoolEventBus>broadcast::Senderall subsystems publish; API → subscribers (SSE)
storage: Arc<Storage>sqlx poolshare recording, audit log, vardiff state, settings
snapshot: Arc<RwLock<NetworkSnapshot>>RwLock snapshotjob_refresh_loop writes; API reads
profile_kind: Arc<RwLock<ProfileKind>>hot-switchableAPI admin route + display
cancel: CancellationTokenpropagationall 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.

TablePhaseWhat
workers1worker list (user, last_seen_at, shares_accepted/rejected, blocks_found)
shares_raw1last hour of raw shares — basis for hashrate computation
shares_agg_5min1aggregated 5-minute buckets — hashrate chart data
blocks_found1block history (worker, height, hash, accepted, reject_reason)
wallet_lifetime8per-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_settings2.5generic KV store (active_profile_kind, donation addresses, the v1.21.0 marquee banner text + expiry, etc.)
vardiff_state2.5per-worker VarDiff snapshots (current_diff, ema, last_share_unix)
audit_log3admin actions (actor, action, target, peer_ip, ok, details)
api_tokens3.2persistent bearer tokens (token_hash, name, scope, last_used_at, revoked_at)
admin_2fa3.3TOTP secrets per user (secret_base32, enabled)
push_subscriptions1web-push subscriptions (PWA)

Timestamp convention. DATETIME columns are written by SQLite itself (CURRENT_TIMESTAMPYYYY-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:

  1. Mining subprotocol (implemented) — channel-based, extranonce-aware, version rolling per BIP-320, per-channel set-target.
  2. Template Distribution Protocol (TDP; foundation in phase 7.6a, wiring in 7.6b deferred) — replaces getblocktemplate with 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:

EventSource
BlockFoundblock_submit_loop
NewJobjob_refresh_loop
SharesAcceptedaggregate_loop
HealthSnapshothealth_check_loop (phase 13b)
UpdateAvailableperiodic_update_check (phase 8e)
ProfileSwitchedAPI admin profile hot-switch
HeartbeatSSE 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)

  1. Parse CLI (clap) + ENV
  2. Load config.toml + (optional) secrets.toml
  3. Validate config (mining.payout_address required, ratelimit constraints, …)
  4. Init tracing subscriber (JSON when logging.json)
  5. Open SQLite + run migrations
  6. Resolve admin profile (persisted value in pool_settings beats the config default)
  7. Construct BitcoinRpc with with_metrics(pool_metrics)
  8. Probe RPC (warning on failure; the daemon still starts)
  9. Construct Notifier from config
  10. Construct PoolMetrics (Arc-shared)
  11. Build the initial job (or skip when RPC failed)
  12. Spawn the Stratum V1 listener (+ TLS listener when cert/key configured)
  13. Spawn the Stratum V2 listener when sv2_listen is configured (the authority key is auto-generated into secrets.toml if missing)
  14. Spawn job_refresh_loop (poll + optional ZMQ)
  15. Spawn block_submit_loop
  16. Spawn aggregate_loop (60s)
  17. Spawn health_check_loop when WARPPOOL_HEALTH_CHECK_INTERVAL_SECONDS > 0
  18. Spawn periodic_update_check when WARPPOOL_AUTOUPDATE_REPO + interval > 0
  19. Spawn the HTTP API (axum on status_listen)
  20. Install signal handlers (SIGTERM → cancel.cancel() → all tasks shut down)
  21. The tokio::main event 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-rpctelemetry (for the RPC latency histogram, phase 16.3)
  • apinotifier + autoupdate + telemetry + storage + …
  • daemon → all library crates plus runtime deps (tokio, sqlx, …)

Testing Strategy

Three layers, plus one extra operator-driven layer:

LayerWhatTool
Unitpure logic per cratecargo test -p warppool-<crate>
Integrationmultiple crates in-process, axum mock serverstests/ per crate
Simagainst the real daemon process, vendor personasdvb-warppool-sim scenario <name>
Regtest E2Eagainst a real bitcoind regtestscripts/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.
  • 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-timeline for 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 with color-mix(), block history as a log stream with timestamps.
  • Modern techniques:
    • text-wrap: balance for headlines
    • Container queries instead of breakpoints — every card adapts locally
    • Anchor positioning for tooltips on chart data points
    • @property for 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-path for the block-found animation
    • View Transitions between light/dark
    • color-mix() + oklch() for perceptually uniform theme shifts
    • Container queries for bento card adaptation
  • 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

RouteContent
/dashboard with the pool-stats bento (hashrate chart, pool stats, network, blocks, top miners)
/overviewaggregated overview (“Übersicht”)
/blocksblock table with pending/accepted/rejected status
/walletswallet-prefix list — nav label “Workers”
/workersper-miner list with user agent + last share — nav label “Miners”
/minersconfigured device list + discovery — nav label “Devices” / “Geräte”
/miners/[id]per-device detail + telemetry history
/hardwarelive detection + profile picker + the profile-comparison table
/hashratehistorical 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
/loginauth page (username/PW + optional 2FA code)
/adminhub for profile switch, backup, tokens, 2FA, audit, notifications
/admin/notificationsbrowser-push settings + server-side sinks — per-sink test buttons with badge state (neutral / ok / err) + last-error tooltip
/admin/bannerv1.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

ComponentWhat
Starfield.svelteCSS-animated drift (60 stars + 4 glow decals, alpha .35-.95)
BentoCard.svelteglassmorphism with backdrop-filter: blur(8px)
StatTile.svelteanimated counter (Web Animations API)
Badge.sveltetone-aware pill (ok / warn / err / neutral)
HashrateChart.sveltehand-rolled SVG chart — auto-scaling Y-axis, gradient fill, best-share dots on a separate right axis
HealthBanner.sveltePhase 14 — consumes SSE health_snapshot + update_available, persistent dismissable banners below the header
EventToasts.svelteblock-found / profile-switch toast notifications
LocalePicker.svelte8-locale dropdown with emoji flags
BestSharesCard.sveltebest-share leaderboard
LayoutTools.sveltecard-grid toolbar — reset a custom card layout + (admin) save the current arrangement as the pool-wide default
IpBanlist.sveltemanual IP ban-list panel (Admin → Profile)
EnergyCard.svelteenergy / solar tracking tile
ConnectModal.svelte“connect a miner” endpoints + Sv2 pubkey, with copy buttons
TelemetrySparkline.svelteper-miner temperature / hashrate sparkline
UpdateStatusBadge.svelteauto-update availability badge
Screensaver.sveltefull-screen idle screensaver — looping splash video + live stat bar (best share, hashrate, miners, blocks, version), for wall/kiosk displays
ScreensaverSettings.sveltetop-bar “Display” popover (next to the locale picker) — toggles the screensaver per device (on/off, idle timeout, stats overlay)
Marquee.sveltev1.21.0 — scrolling announcement banner, fed by the banner field of /api/overview, pauses on hover and respects prefers-reduced-motion
DonationCard.svelteoperator 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-motion fallback + 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 a role="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 daemon push_send_loop delivers (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):

CrateTestsWhat
warppool-api252axum 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-v2138NOISE 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-storage117sqlx 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-v1152 (+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-telemetry75vendor probes (AxeOS / cgminer mock / Avalon estats), mDNS discovery, HTTP sweep, PoolMetrics + histogram
dvb-warppool-setup51wizard endpoints, install marker, Umbrel env defaults, goPool path resolver
warppool-config56TOML schema, coinbase_splits, VarDiffSettings/RetentionConfig/AutoProfileConfig::validate, retention/upnp blocks, comment-preserving save, profile-tier mapping
warppool-health35RPC call, bitcoin-conf parser, snippet generator, warnings aggregation
warppool-notifier51render_text, EmailSink/SlackSink construction, sink skip when env missing, MarkdownV2 escaping of miner/operator fields
warppool-autoupdate41version compare, asset matching, download_verified, atomic_swap, cosign subprocess
warppool-share-validator26sha256d vectors, dedup, network/pool target checks
warppool-bitcoin-rpc37 (+3 regtest, #[ignore])RPC envelope parsing, ZMQ frames, submit_block null result, transient-error retry, multi-node failover + broadcast
warppool-bitcoin-ipc12Cap’n Proto mining client for Bitcoin Core >= 31, TemplateSource trait, GBT/IPC selection + socket precheck
warppool-uninstall22per-OS path plans, marker gating, JSON roundtrip
warppool-job-builder26coinbase construction, BIP-34 height encoding, merkle branches, template mintime
warppool-translator13 (+5 e2e, +3 bin)extended-channel state machine, BIP-320 version rolling, prev-hash reverse
warppool-hwdetect11sysinfo detection, container env, recommendation reason keys
warppool-profiles7profile resolution, retention monotonicity, rpm→bucket rate derivation
warppool-simulator4sim-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:

TestCrateWhat
e2e.rsstratum-v1TCP listener + ShareValidator + sim-miner roundtrip with a real subscribe/authorize/submit flow
regtest_e2e.rsbitcoin-rpc#[ignore] — against a running bitcoind regtest
update_apply_with_cosign_verify_but_no_env_returns_500apiaxum mock GitHub + mock asset server
metrics_renders_pool_counters_when_pool_metrics_setapi/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

ScenarioDurationStatusWhat it verifies
solo-block-found30s✓ executablesim miner connects, block-found event fires
connection-storm-defense60s✓ executablerate limit kicks in under N parallel auth attempts
enterprise-load-smoke5min✓ executableN parallel miners of mixed personas run cleanly
small-24h-stability24hplan-onlySmall profile stays under 200 MB, no panics
rpc-outage-recovery10minplan-onlypool 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):

PersonaNominal hashrateTLSNote
nerdminer-v250 GH/snoplain only
bitaxe-ultra2 TH/syesclean reconnect
nerd-octaxe4.5 TH/syesNerdNOS quirks
avalon-q90 TH/snoCGMiner user agent
antminer-s23-pro580 TH/syesovert ASIC boost
adversarymalicious inputs

Failure modes

dvb-warppool-sim failures <mode> injects production problems:

  • rpc-down — Bitcoin Core unreachable
  • zmq-disconnect — block notify gone
  • tls-handshake-fail — expired cert
  • miner-hang — connection stalls after N shares
  • network-latency — artificial RTT
  • packet-loss — N% packet loss
  • invalid-template — broken coinbase tx
  • reorg — tip gets rolled back
  • memory-pressure — pool under RAM pressure
  • disk-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:

TestWhat
regtest_blockchain_info_returns_regtest_chainchain == "regtest"
regtest_getblocktemplate_workstemplate carries version + bits + prev hash
regtest_submit_invalid_block_is_rejectedtrivially 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:

CrateBenchHot path
warppool-share-validatorvalidateper accepted+rejected share
warppool-job-builderbuild_jobper new template
warppool-stratum-v1vardiffper 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_panics drives StratumRequest::parse

    • sanitize_json_escapes through 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 main cargo build/test --workspace never pulls in the nightly-only dependency. Crashes go into fuzz/artifacts/; minimise and add the input as a regression case in fuzz_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 --workspace
  • cargo 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 with packaging/macos/build-macos-release.sh, the .msi is paused
  • repro.yml — reproducible-builds verify (build twice + sha256 diff)
  • docs.yml — mdBook → docs.warppool.org
  • benches.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

BenchCrateHot-PathFrequency in Pool
validatewarppool-share-validatorShareValidator::validate() per accepted+rejected shareper-share (most frequent)
build_jobwarppool-job-builderJobBuilder::build() per new block templateper-job (~every 30-60s)
vardiffwarppool-stratum-v1VarDiff::observe_share() per accepted shareper-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 sha2 to 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)

BenchTimeThroughput
validate_full/0 (no merkle branches)435 ns2.3M shares/s
validate_full/8 (typical regtest)1.12 µs890K shares/s
validate_full/12 (typical mainnet)1.48 µs677K shares/s
sha256d_80b_header76 ns
sha256d_500b_coinbase245 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)

BenchTimeThroughput
build_job/0 (empty / regtest)1.25 µs
build_job/10018.2 µs
build_job/1000161 µs
build_job/4000 (typical full block)634 µs1.5K jobs/s
merkle_branches/4000318 µ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)

BenchTime
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:

BenchM-Series (v1.0.18)Ryzen 8745HS (v1.0.23)
validate_full/0435 ns334 ns
validate_full/81.12 µs1.15 µs
validate_full/121.48 µs1.56 µs (~642K shares/s)
sha256d_80b_header76 ns92 ns
sha256d_500b_coinbase245 ns252 ns
build_job/01.25 µs0.69 µs
build_job/4000634 µs598 µs
merkle_branches/4000318 µs385 µs
vardiff_observe_share_hold5.2 ns3.3 ns
vardiff_observe_share_retarget39.0 ns28.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:

  1. Stratum core, in-processscenario enterprise-load-smoke spawns 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.
  2. Full daemon, externalload <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)

MinersDurationAccepted sharesRateRejected
10060 s27,667~461 shares/s0
1,00060 s277,256~4,621 shares/s0

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)

ProfileMinersSubmits processedDaemon RSS peakDaemon CPU
farm-large100~114 /s~48 MB~4–5 % of one core
farm-enterprise1,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.

TemplateGBT + ZMQ (default)Core IPC (opt-in)
empty block88.9 ms5.0 ms
320 transactions101.4 ms12.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:

QuestionHint
“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:

  1. Run cargo bench --bench <name> locally → confirm
  2. git bisect between the last known-good version and HEAD
  3. 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

PathWhy not
Stratum V1 TCP I/OTokio 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 RPCNetwork IO + Bitcoin-Core-side dominates. The Phase 16.3 RPC-latency histogram is the right observation.
Translator V1↔V2 mappingPer-job (every 30-60s), not latency-critical. Would be effort for little benefit.
Storage SQLsqlx + WAL mode dominates. If needed, bench directly with the sqlite3 CLI.
Notifier sinksHTTP/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.

PhaseContentStatusTests*
7.1Foundation — codec + 12 messages + NOISE_NX+22
7.2Mining-Protocol state machine+16
7.3aSv2 server crate (TCP + NOISE + session loop)+2
7.3bDaemon wiring + CLI (gen-sv2-key)(live)
7.4V1↔V2 translator proxy + extended channels+22
7.5Job distribution + V1 mining.notify mapping+11
7.5bProduction polish (prev_hash byte order + BIP-320)+6
7.6aTemplate-distribution foundation (7 TDP messages)+13
7.6bTDP 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.rsProtocolMessage trait + 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_payload methods.
  • noise.rsNoiseSession state machine over the SRI noise_sv2 handshake / 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: AwaitSetupMiningProtocolClosed. 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:

  1. V1 mining.subscribebuffered (no reply yet)
  2. V1 mining.authorizeOpenExtendedMiningChannel{user_identity, min_extranonce_size=4} upstream
  3. V2 OpenExtendedMiningChannel.Success → deferred V1 replies (subscribe result with extranonce_prefix as extranonce1, set_difficulty from target, authorize OK)
  4. V1 mining.submit → V2 SubmitSharesExtended with pad/truncate to channel.extranonce_size
  5. 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 by job_id
  • SetTarget (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.configure handler: negotiates mask = miner_mask & ours_mask; ours_mask = 0x1FFFE000 (16 bits, bits 13–28) when the upstream job has version_rolling_allowed=true, otherwise 0.
  • mining.submit parses an optional 6th param version_bits_hex.
  • V2 SubmitSharesExtended.version = (job.version & !mask) | (miner_bits & mask) per BIP-320 XOR — previously hardcoded version: 0 (MVP bug).

Phase 7.6a — Template-Distribution Foundation

7 new TDP messages (its own sub-protocol, separate from Mining):

IDName
0x70CoinbaseOutputDataSize
0x71NewTemplate
0x72SetNewPrevHashTdp (distinct wire format from Mining 0x20!)
0x73RequestTransactionData
0x74RequestTransactionData.Success (with SEQ0_64K<B0_16M> tx list)
0x75RequestTransactionData.Error
0x76SubmitSolution

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 OnFailure rollback (warppool-autoupdate crate) ✅
  • 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-apps submission.
  • 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 .dmg in CI — likewise no macOS runner; the .dmg is built by hand per release via packaging/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-profiles crate (Small/Medium/Large/Enterprise) — implemented + tested
  • warppool-config crate (schema port from dvb-gopool) — implemented + tested
  • warppool-hwdetect crate — hardware auto-detection + profile recommendation
  • Coinbase splits (pool fee + donation, default “No Pool Fee”, relevant for Large/Enterprise / multi-user solo)
  • warppool-simulator crate (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-simsolo-block-found scenario 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-storagevardiff_state table + load/save
  • Session integration — load on Authorize, observe on Accept, retarget with set_difficulty + pool-target update, async save
  • [vardiff] config section + StorageVarDiffStore adapter 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-Rust zeromq crate, 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_loop select arm. Polling remains as fallback.
  • --no-zmq CLI 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) — Session generic over AsyncRead+AsyncWrite, tls::load_tls_config (rustls + aws-lc-rs), StratumServer::serve_tls_with_listener with tokio-rustls::TlsAcceptor, daemon optionally spawns a parallel TLS listener when stratum_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 the accept loop before the semaphore check, in handle_authorize before 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-secret subcommands for setup. When admin_password_hash or jwt_secret in 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. --json global flag for scripting. Pretty-print with humanized hashrate units (H/s → EH/s).
  • Sim-Runtime: solo-block-found for real (THIS SESSION) — JsonStratumMiner as a real TCP Stratum V1 client, persona-driven submit rate with Poisson jitter + 50ms cap. dvb-warppool-sim scenario solo-block-found --duration N spawns 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 N spawns 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 N spawns N parallel JsonStratumMiner with 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_tokens table with SHA-256 hash (plaintext never persisted), format wpat_<32hex>. mint_api_token() + sha256_hex() in api/auth. AuthIdentity extractor distinguishes wpat_ prefix → DB lookup via find_active_token_by_hash; otherwise continues with JWT. Routes: POST/GET/DELETE /api/admin/tokens (with audit log). Soft-delete via revoked_at. CLI subcommands token-create -n -- --ttl --scope / token-list / token-revoke <id>.
  • Auth Subphase 3 — 2FA-TOTP (THIS SESSION) — admin_2fa table 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 accepts totp_code; required when 2FA is active. 401 with requires_2fa: true flag when code is missing. CLI subcommands twofa-status/setup/enable/disable, login --totp CODE. Phase 3 complete with this.
  • Profile hot-switch + X-Forwarded-For (THIS SESSION) — pool_settings KV table, AppState.profile_kind is now Arc<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 from X-Forwarded-For header (first element). Protects against IP spoofing on direct internet access.
  • Tiered retention + aggregation worker (THIS SESSION) — shares_raw + shares_agg_5min tables, record_share + aggregate_5min + evict methods in storage. ShareSink trait in stratum-v1 (Session calls sink.record after Valid/BlockFound in a spawned task). StorageShareSink adapter 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_log table (id/at/actor/action/target/peer_ip/ok/details). AuditSink trait in warppool-api::auth + StorageAuditSink adapter in the daemon. Auth routes fire login.ok / login.fail. Protected endpoint GET /api/admin/audit?limit=N&actor=.... Peer-IP via optional ConnectInfo<SocketAddr> (daemon uses into_make_service_with_connect_info::<SocketAddr>). CLI audit --limit --actor subcommand 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.ts with Svelte 5 runes ($state in .svelte.ts module), cookie-based session (credentials:‘include’ on all fetches), localStorage holds only username + marker (token is HttpOnly cookie). /login route with username/password + optional TOTP code (appears after requires_2fa: true response). 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.svelte with auth guard (redirect /login if unauth). Header shows “Admin” link only when authed. lib/api.ts admin.* sub-namespace with all endpoints.
  • UI Subphase 3: Hashrate chart (with /api/hashrate, HashrateChart.svelte)
  • Live updates via SSE (/api/eventslib/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 daemon push_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 .dmg is built by hand via packaging/macos/build-macos-release.sh and 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 Foundationwarppool-stratum-v2 crate: codec (binary frame with compact_int), messages (SetupConnection, OpenStandardMiningChannel, SubmitSharesStandard, +Success/Error), noise (NOISE_NX_25519_ChaChaPoly_BLAKE2s via snow)
  • 7.2 State machineMiningServerSession with AwaitSetup → MiningProtocol → Closed phases, ChannelRegistry with per-channel extranonce prefix + duplicate-sequence detection
  • 7.3a TCP/NOISE serverSv2Server with 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 (OpenExtendedMiningChannel 0x13, OpenExtendedMiningChannelSuccess 0x14, SubmitSharesExtended 0x1F, OpenMiningChannelError 0x12, SubmitShares.Success 0x1C, SubmitShares.Error 0x1D) including server handler with real Success/Error responses (old Phase-7.3 TODOs resolved). Public Sv2Client (TCP+NOISE initiator, previously cfg(test) only). New crate warppool-translator + binary dvb-warppool-translator (clap CLI). Per V1 connection the translator opens a V2 connection, buffers mining.subscribe until mining.authorize arrives, then sends OpenExtendedMiningChannel upstream. mining.submitSubmitSharesExtended with 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_hash byte order in V1 mining.notify is 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.configure handler 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 reports version_rolling_allowed=true, otherwise 0. With agreed=0 → response {"version-rolling": false}. mining.submit parses optional 6th param version_bits_hex; with rolling active, the translator builds V2 SubmitSharesExtended.version = (job.version & !mask) | (miner_bits & mask) (BIP-320 XOR). Previously version: 0 was 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: NewMiningJob 0x1E (for standard channels, with finished merkle_root), NewExtendedMiningJob 0x22 (for extended, with version_rolling_allowed + SEQ0_255<B032> merkle_path + B0_64K coinbase_prefix/suffix), SetNewPrevHash 0x20 (channel_id/job_id/prev_hash/min_ntime/nbits), SetTarget 0x21 (channel_id/maximum_target). Sv2 server fan-out: JobUpdate extended 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-up SetNewPrevHash for each open channel (Standard → NewMiningJob+SNPH, Extended → NewExtendedMiningJob+SNPH); previously “log only”. Translator learns NewExtendedMiningJob + SetNewPrevHash + SetTarget; caches both job halves, pairs them by job_id, maybe_emit_notify() sends V1 mining.notify with all 9 params (job_id/prev_hash_hex/cb1/cb2/merkle_branches/version_BE/nbits_BE/ntime_BE/clean_jobs); clean_jobs=true on 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): CoinbaseOutputDataSize 0x70 (Pool→Node), NewTemplate 0x71 (Node→Pool, with future_template bit for pre-build, coinbase_prefix/outputs B0_64K, SEQ0_255 merkle_path), SetNewPrevHashTdp 0x72 (note: separate wire format from Mining 0x20! template_id u64 + prev_hash + header_timestamp + nbits + target), RequestTransactionData 0x73 (Pool→Node), RequestTransactionData.Success 0x74 (with SEQ0_64K<B0_16M> transaction_list — u16 count + each tx u24-LE-prefixed), RequestTransactionData.Error 0x75, SubmitSolution 0x76 (Pool→Node with finished coinbase). Session state machine: new phase SessionPhase::TemplateDistribution, handle_setup accepts Protocol::TemplateDistributionProtocol (=2) and switches to the phase. TDP frames are accepted in the phase and acknowledged with tracing::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 Wiringshipped 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 with bitcoin-sv2-tp packaging (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 the mining.capnp schema (clients built against older schemas fail hard on Init.makeMining; createNewBlock/checkBlock gained a required context parameter, getCoinbaseTx changed 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 into stratum libs + sv2-apps binaries/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-in warppool-bitcoin-ipc template source (Cap’n Proto against mainline Core >= 31) now feeds the same job_refresh_loop/JobBuilder path through a shared TemplateSource trait; 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::webpush module with pure-Rust crypto stack (p256 for ECDH/ECDSA, ece v2 for AES-128-GCM Content-Encoding RFC 8188, jsonwebtoken for 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 subcommand gen-vapid-keys prints a TOML snippet to stdout (+ pub-key to stderr for UI hint). secrets.toml fields vapid_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’s PushManager.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 with vapid_public_key + web_push_sender: Option<Arc<WebPushSender>>.
  • 21.3 Daemon push-send loop (THIS SESSION) — push_send_loop subscribes to PoolEventBus, fires on BlockFound/HealthSnapshot-with-warnings/UpdateAvailable. Per sub, spawn a task so a slow push service doesn’t block the others. On WebPushError::Gonedelete_push_subscription_by_id (browser invalidated the sub). On other errors → touch_push_subscription(error) for UI debugging. render_push_event helper 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). New webPush store in notifications.svelte.ts with 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/notifications with subscribe/unsubscribe button + iOS hint (required: add to home screen) + operator setup snippet.
  • 21.5 Tests + docs (THIS SESSION) — notifications.md new “Web Push (PWA, VAPID)” section with operator setup + user subscribe flow + events that push + iOS quirks + security note. configuration-reference.md new 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 ElectricityConfig in MiningConfig.electricity with 3-layer priority (Solar/TOU/Default). Pure-fn effective_rate(now) + slot_matches helper, 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/energy with current_power_w, current_eur_per_hour, last_{24h,7d}_kwh+eur, current_rate_source (“tou:HT”|“default”|“none”). Storage helper energy_kwh_in_last_hours() aggregates across all miners. Pure-fns sum_current_power + describe_rate_source separately testable. +8 tests.

  • 20.3 Health anomaly detector (THIS SESSION) — Pure-fn detect_anomalies(points, now, thresholds) in new warppool-storage::anomalies module. 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). Configurable AnomalyThresholds. Stale-data blocks other heuristics (no point analyzing an inactive miner). Thermal+Hashrate are deduplicated (no double-alert). API endpoint GET /api/miners/:id/alerts. +13 tests (11 detector + 2 api).

  • 20.5 Solar HA provider (THIS SESSION) — Home Assistant REST API adapter. SolarConfig extended with kind (“home_assistant”), url_env/token_env for auth, pv_entity_id (required) + optional consumption_entity_id, poll_interval_secs (default 60), surplus_buffer_w (default 200W), stale_after_secs (default 300s), excess_rate_eur_kwh (default 0.0). New SolarSnapshot {pv_w, consumption_w, at} + SolarSnapshotCache = Arc<RwLock<Option<...>>> type. effective_rate restructured: effective_rate_with_context(now, solar_snap, pool_power_w) is the new main procedure with 3-layer priority. API side: Pure-async-fn fetch_ha_entity_w parses HA’s /api/states/<id> response incl. unit conversion (W/kW/MW). fetch_ha_snapshot orchestrates PV + optional consumption. /api/energy response extended with solar: Option<SolarStatus> field (pv_w/consumption_w/excess_w/age_seconds) and new current_rate_source = "solar-excess" value. Daemon: solar_poll_loop task spawns when solar.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/alerts with severity tone), history card with range picker (1h/6h/24h/7d) + 5 TelemetrySparkline components (Hashrate/Power/Temp/Voltage/Fan). Refresh 30s. TelemetrySparkline.svelte as a simple SVG line chart, parameterizable via field/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 an on_health_alert: bool config field (default true, Critical alerts indicate hardware damage). Sink-specific rendering: ntfy with rotating_light tag + priority 5 for Critical; Slack with :rotating_light: emoji + severity label in body; Telegram uses render_text fallback. Daemon-side anomaly_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.svelte component 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 keys energy.* 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 warnings clean.
  • 32.2 🔴 CRITICAL: Merkle root wtxid→txid + byte order (THIS SESSION) — job-builder used tx.hash (segwit wtxid) instead of tx.txid AND 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 with bad-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_jobvalidator.clear_all() (was unbounded for the process lifetime); (d) Sv2 connection cap: max_connections was dead config → now a semaphore (0=unlimited); (e) Sv2 extranonce length check in precheck_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); new server.cookie_secure config flag (session cookie ; Secure when 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_prefix length validation (≤64 bytes — same silent-block-loss class as Merkle: too long → coinbase scriptSig >100 bytes), Sv2 read_buf cap at MAX_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) → daemon submitting 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 thrown bad-txnmrklroot here. 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/JobSnapshot carry extranonce_total_size (= V1 extranonce1+2); new pow::standard_extranonce fills the extranonce gap deterministically per channel (4-byte prefix + null padding); build_job_frames computes the real per-channel merkle root (prefix + standard_extranonce + suffix → txid → merkle_path); new pow_check_standard does the full PoW check + buffers a BlockSolution (same daemon submit path as Extended). merkle_root field removed from JobUpdate (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: getblocktemplate was hardcoded with ["segwit"] — Signet requires ["segwit","signet"] → RPC error -8, no template (occurs ONLY on Signet, never regtest/main/test). Fix: network-dependent rules via gbt_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, nbits 1d15102a, 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=off in the default log filter — the ERROR spam “Invalid DNS message” on malformed LAN packets, noticed during the Signet test); Sv2 ChannelRegistry counter overflow-safe (wrapping_add instead of += 1, no debug panic on u32 overflow); codec::decode_str_u8 now returns FrameError::InvalidUtf8 on UTF-8 error instead of misleading BadCompactInt; Sv2 connection-cap hot-switch coupled to the admin profile switch (semaphore pulled from the serve loop into Sv2ServerHandle + resize_connection_cap/connection_cap analogous to V1; daemon StratumCapResizer now 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; Secrets Debug derive (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 VarDiffresolved in v1.12.0: Sv2 channels now run the same VarDiff engine 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_handle was consumed in the daemon setup by sv2_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 a NewExtendedMiningJob frame and the V1 sim-miner never receives a mining.notify. Phase 7.3b notes said “job push is forwarded to the handle below in job_refresh_loop” — but that was never written. Fix: Sv2ServerHandle annotated with #[derive(Clone)] (was previously unique-owned); in the daemon setup sv2_handle.clone() into a pushable variable before serve(); job_refresh_loop signature extended with Option<Sv2ServerHandle>; after handle.push_job(...) (V1) additionally sv2.push_job(JobUpdate {...}) for V2 with mapping from V1 StratumJob (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) New pow module in crates/stratum-v2/src/pow.rs with sha256d / reconstruct_coinbase / compute_merkle_root / build_header / nbits_to_target / hash_meets_target (+ 6 unit tests, sha2 dep new); (2) MiningServerSession gets current_job: Option<JobSnapshot>, set via record_current_job from the server loop after every broadcast (server loop converts JobUpdate.prev_hash_be → internal LE per byte-reverse); (3) handle_submit_share_extended does inline PoW check (pow_check_extended): coinbase = prefix+pool_extranonce+miner_extranonce+suffix → txid → merkle_root via path → 80-byte header → sha256d → compare vs nbits_to_target (BlockFound) or channel.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 via drain_block_solutions + broadcasts via new Sv2ServerHandle::block_solution_tx. Daemon block_submit_loop extended: second select arm for Sv2 solutions, converts via sv2_solution_to_event to V1 BlockFoundEvent (Sv2 coinbase already has extranonce built in = exactly what build_full_block_hex expects as stripped coinbase). Submit logic extracted into shared submit_found_block fn (V1 + Sv2 share it). Daemon subscribes subscribe_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 warnings clean. 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 in shares_raw / pool_stats. Fix: (a) New ShareSink trait in crates/stratum-v2/src/server.rs analogous to V1, Sv2Server::with_sinks(...) constructor; (b) MiningServerSession collected accepted-share records (worker + difficulty + was_block) in an internal accepted_buffer, server loop drains after every process_frame and spawns share_sink.record() per entry (non-blocking); (c) Difficulty is computed from the channel target via top-8-byte approximation (sv2_target_to_difficulty); (d) Daemon StorageShareSink impls both V1 ShareSink and V2 warppool_stratum_v2::server::ShareSink traits — same record_share call underneath.
  • 🐛 BUG FOUND: worker counter generally broken (THIS SESSION) — While implementing the above Sv2 wiring it turned out: Storage::record_share writes only to shares_raw, not to the workers table. pool_stats.total_shares_accepted however aggregates SUM(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 checked total_blocks_found, which is updated via separate touch_worker(_, _, _, 1, _) call in the block_submit_loop. Fix: record_share now additionally calls touch_worker(worker, 1, 0, 0, None) — accepted counter += 1, rejected/blocks stay zero (blocks_delta is still set separately by block_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 used touch_worker directly, 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 in apps/dvb-warppool-setup/src/main.rs that checks: (a) bitcoind (or bitcoind.exe on Windows) in PATH via pure-Rust PATH walk, (b) execute bitcoind --version first line if binary found, (c) OS detection via std::env::consts::OS, (d) Linux distro detection via /etc/os-release (ID + ID_LIKE → debian/fedora/arch/alpine/other). Returns BitcoinInstallStatus { binary_path, version_string, os, linux_distro, suggestion }. Pure-fn install_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-status on 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 gets install-card-warn class 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.svelte upgraded 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-wizard index.html with identical values — layer 2 via .stars::after pseudo-element (setup wizard has only 1 .stars div). @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 correct Content-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-WarpPool is no longer operator-configurable in the setup wizard (UI card “Branding” replaced with a pure “Server Location” card, default value "Korschenbroich NRW, Germany"). Backend in apply() deliberately ignores incoming status_brand_name fields (let _ = form.status_brand_name;) so older wizard versions or malicious POSTs cannot overwrite the brand. BrandingConfig::default() sets status_brand_name = "dvb-WarpPool" as hardcoded brand identity. Live-verified: POST with injected status_brand_name="someone-tries-to-rebrand-this" → config.toml shows status_brand_name = "dvb-WarpPool" (ignored) and server_location = "Korschenbroich NRW, Germany" (accepted).
  • 30.5 Submit button only active when required fields filled (THIS SESSION) — Analogous to dvb-goPool: #submit-btn is initially disabled + greyscale, only becomes active when all required fields are valid. Reactive input listener 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 the disabled attribute 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_addr was 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). required attribute on <input> + JS validation checks tcp://host:port format. 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: I18N object with ~55 keys × 2 locales (DE as default, EN as fallback). All h2/labels/buttons/hints/notes have data-i18n="key", placeholders with data-i18n-placeholder, HTML-permitted with data-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) use t() directly; install status is re-rendered on locale switch via cached window._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.sh starts bitcoind-regtest with RPC :28443 + ZMQ hashblock :28332 + cookie auth. Local config in /tmp/warppool-live/{config.toml,secrets.toml} with regtest address bcrt1qtau…tklgl8mmt, profile=klein (small), min_diff=0.001, ratelimit off. Daemon --release build (10.9 MB) run against the regtest node, UI served by the daemon via --ui-dir. 101 blocks generated via generatetoaddress to 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 as accepted: true in /api/blocks (see next point for the bug fix that enabled this).
  • 🐛 BUG FOUND: submit_block null response (THIS SESSION) — Bitcoin Core’s submitblock RPC returns "result": null on successful submit. The bitcoin-rpc client in call_once (crates/bitcoin-rpc/src/lib.rs:224) called envelope.result.ok_or(MissingField("result"))?, which on Option<Value> deserialize maps both missing-field and value-null to None → false MissingField error. Result in the first live run: pool reported “rpc-error” + accepted=false for all found blocks, even though Bitcoin Core actually accepted them (chain height grew nonetheless). Fix: unwrap_or(serde_json::Value::Null) instead of ok_or(MissingField)submit_block’s match arm already correctly implemented the Value::Null path. 2 new tests: submit_block_null_response_classified_as_accepted and submit_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_log had evict_audit_older_than in storage but was never called in the daemon → real gap since Phase 3. shares_raw/shares_agg_5min/miner_telemetry_raw/miner_telemetry_agg5 had evict calls in aggregate_loop but with literal hardcoded 3600/7*24*3600 values. New RetentionConfig in crates/config/src/lib.rs as [retention] block with 5 TTL fields (defaults match the old hardcoded values; audit_log_secs default 90 days). aggregate_loop signature extended with retention, all evict calls now use config values. audit_log eviction 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 via semaphore.add_permits(delta) (immediately visible); decrease via spawned acquire_many_owned(delta).forget() task that waits until enough permits free up — natural drain without kicking existing connections. Current cap is held in the new cap_tracker: Arc<AtomicUsize> in the handle. New ConnectionCapResizer trait in the warppool-api crate (crates/api/src/lib.rs); AppState.connection_cap_resizer as optional Arc<dyn ...> (None in tests). post_admin_profile handler now calls resize(new_profile.connection_cap) and reflects this in the response (new connection_cap field). Daemon-side StratumV1CapResizer adapter with OnceLock<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: on onerror the socket is explicitly closed and a custom reconnect via exponential backoff (BACKOFF_MS = [1s, 2s, 4s, 8s, 16s, 30s]) is scheduled. Counter state.reconnectAttempts (UI-readable for “reconnecting…” banner display), reset to 0 after successful onopen. Pure helper backoffMsForAttempt(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 with UpnpConfig { enabled, renew_interval_secs, forwards: Vec<UpnpForwardSpec> }; each UpnpForwardSpec has port, protocol (tcp/udp), lease_seconds, description + a validate() fn that checks port>0, protocol∈{tcp,udp} (case-insensitive), lease∈60..=86400. Daemon-side (apps/dvb-warppool-daemon/src/main.rs): upnp_renew_loop as tokio task; per tick spawn_blocking(run_upnp_renew_once) which calls igd::search_gateway once + then add_port per spec (idempotent on the router side). Pure-data UpnpRenewReport { ok, failed, last_error } so the loop and the blocking helper are separately testable. clamp_upnp_interval clamps 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_submit in 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) but share.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 method MiningServerSession::record_job_broadcast(job_id) called after build_job_frames (server loop in handle_connection), iterates the ChannelRegistry and sets ch.current_job_id. Previously the current_job_id field 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 a record_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 additional push_job + drain of the resulting mining.notify line before mining.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 signature HashMap<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 fn prune_pending_submits is called before every insert: first TTL pruning (single-pass O(n) retain), then — if still above cap — oldest-by-timestamp eviction. Both remove sites in SubmitSharesSuccess/SubmitSharesError handlers 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 via build_job_frames (stratum-v2/src/server.rs:323); translator uses OpenExtendedMiningChannel by 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) in ui/static/icons/: 16/32/64/180/192/256/384/512 square PNGs with preserved transparency for PWA purpose: any. Additionally icon-{192,256,384,512}-maskable.png for 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.webmanifest icons array extended with 4× any + 2× maskable PNG entries (favicon.svg remains primary for modern browsers with any-sized SVG support). app.html new: <link rel="alternate icon" href=".../favicon.ico"> + <link rel="apple-touch-icon"> + 3 apple-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 #05060B background.
  • 25.4 README header with logo (THIS SESSION) — README.md gets 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 in build/ 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:text with 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-brand CSS var removed from app.css, .title + .dot original 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.svg was an old abstract purple-cyan design with no logo relation. Newly drawn SVG (ui/static/favicon.svg) adopts the logo DNA: dark #05060B rounded-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 warnings green. Fixed: 26× field_reassign_with_default annotated over test modules with #[allow(...)] (6 test modules: config / api / notifier / stratum-v1::ratelimit + ::vardiff / job-builder); 2× manual_ignore_case_cmp in avalon-probe (.eq_ignore_ascii_case); 1× type_complexity in whatsminer-probe (5-tuple → type StatsExtract); 2× get_first in stratum-v1::messages (.first() instead of .get(0)); 2× match_like_matches_macro in the daemon (debounce checks); 1× explicit_auto_deref in notifier metrics_snapshot; 1× useless_vec in webpush test (&[0u8; 16]); 1× expect_fun_call in telemetry; 1× non_snake_case test name (cgminer_status_E_yields_error_e_); 1× doc_lazy_continuation in hwdetect; 1× too_many_arguments in job_refresh_loop via #[allow] (central wiring wrapper, 10 args are essential). Bench crates/job-builder/benches/build_job.rs rewritten directly to MiningConfig { ..Default::default() } pattern (not test code, therefore no allow).
  • 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) → NotifierTest subcommand re-implemented with real POST /api/admin/notifier/test call incl. sink=all special path and tabular ok/err output; SetProfile doc comment clarified. 2× in sim-binary stubs (Load/Failures) — replaced by Phase-3 scenario subcommands → clear exit-2 with pointer to existing command instead of println!("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-features now 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 warning th.num unused in /blocks → resolved by correctly marking the height-column header as class="num", semantically consistent with td.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_error field in /api/miners shows 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::BraiinsOS enum 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). New probes/braiins.rs with own field-schema extension: power_consumption_w (Braiins exposes this, stock doesn’t), voltage with V→mV heuristic (< 100 = V), miner_version/bos_version as firmware string. Default model “Antminer (Braiins OS)” when STATS has no Type field. Skipped temp_avg/temp_max keys so the hottest chip comes out of temp1..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_avalonq Home Assistant template + gbechtel-beck/avalon-q-controller. New Vendor::AvalonQ enum variant + parse("avalonq"|"avalon-q"|"avalon_q") + display “Avalon Q (Canaan)”. New probes/avalonq.rs with AvalonQVendor: CgminerVendor. Hashrate from THSspd (×1000 for GH/s) with fallback to SUMMARY fields. Temperature TMax preferred, fallback TAvg. Power from Cur_Load. Fan maximum from Fan1-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) -> String renders the last_telemetry_json fields from the miners table 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_loop signature extended with discovery_cache: Option<DiscoveryCache>, discovered_telemetry: Option<DiscoveredTelemetryCache>, auto_probe_discovered: bool. If env WARPPOOL_AUTO_PROBE_DISCOVERED=true|1|yes is 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; /metrics renders them with label="discovered" so PromQL can separate. 2 helper tests + 1 e2e test.

  • 22.3 Tests + docs (THIS SESSION) — docs/book/src/observability.md extended 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.md extended with WARPPOOL_AUTO_PROBE_DISCOVERED env 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 with cargo_bench_support feature (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); artifact criterion-reports-<sha> with 30d retention. docs/book/src/benchmarks.md with 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) — Notifier struct gets counters: tokio::sync::Mutex<HashMap<(sink, event_kind, result), u64>>. inc_counter called from notify() + test_sinks(). metrics_snapshot() returns Vec<NotifierMetric> for /metrics handler. Per-sink view on send successes/failures.
  • 16.2 Worker lifecycle metrics (THIS SESSION) — New crates/telemetry/src/metrics.rs with PoolMetrics struct: AtomicU64/AtomicI64 counters for workers_authorized_total, workers_disconnected_total, active_connections_{v1,v2}. NotifierConnectionSink gets metrics: 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>> + builder with_metrics(arc). BitcoinRpc::call wrapped in Instant::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). Inner call_with_retry is the old logic moved.
  • /metrics handler in warppool-api extended 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 ConnectionSink in crates/stratum-v2/src/session.rs with signature identical to v1. Sv2ServerConfig.connection_sink: Option<SharedConnectionSink>. Sv2Server::with_connection_sink(...) as new ctor. handle_connection wraps the loop in async {...}.await so on_disconnect fires on both paths (Ok-exit + Err-exit). notified_users: HashSet<String> tracks seen user_identity strings; after each session.process_frame, iterate session.channels(), every NEW user_identity fires on_authorized (spawned). On loop exit: on_disconnect for 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. Shared handle_disconnect method 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) fire NotifyEvent::RpcDown {duration_secs:0}, on (Some(false), true) fire RpcRecovered with down_since.elapsed().as_secs() as debug log. Inline 4-arm match.
  • 15.2 MinerDisconnect (V1) (THIS SESSION) — New ConnectionSink trait in warppool-stratum-v1 analogous to ShareSink: on_authorized(worker, peer) + on_disconnect(worker, peer). SessionRunArgs gets connection_sink: Option<SharedConnectionSink>, Session::run fires hooks in spawned task. Daemon adapter NotifierConnectionSink {notifier, debounce, last_notified: Mutex<HashMap<String,Instant>>} with env-configurable WARPPOOL_DISCONNECT_DEBOUNCE_SECS (default 30).
  • 15.3 Email sink (THIS SESSION) — lettre = "0.11" workspace dep with tokio1-rustls-tls + smtp-transport + builder + hostname + ring features (rustls instead of openssl-sys). EmailSink parses smtps://user@host:465 (implicit TLS) or smtp://user@host:587 (STARTTLS); auth via password_env config field. Extended with on_miner_disconnect + on_rpc_down toggles. 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>>. New Notifier::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 log notifier.test. 6 new API tests.
  • 15.6 UI admin section (THIS SESSION) — src/lib/api.ts admin.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 for eval) + scripts/regtest-down.sh (clean stop + optional --purge). crates/bitcoin-rpc/tests/regtest_e2e.rs with 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 new HealthBanner.svelte component renders them as a persistent banner below the header (visible on all pages via +layout.svelte). TypeScript types (ui/src/lib/types.ts): HealthSnapshotEvent + UpdateAvailableEvent interfaces, extended into PoolEvent union. PoolEventType derived type automatically gets both. events.svelte.ts ALL_TYPES array extended with ‘update_available’ + ‘health_snapshot’ so EventSource registers the named events. Component behavior: lastHealth + lastUpdate state, both separately dismissable per session (no localStorage — after tab reload they come back). maybeRevive helper: 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-health crate (Phase 13a) for continuous Bitcoin Core diagnostics in the running pool. env var WARPPOOL_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 new PoolEvent::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-line bitcoin_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-existing io::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). BitcoinHealthReq gets optional bitcoin_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. BitcoinHealth gets recommended_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 igd crate (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). contacted field 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.md with 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 to lto = "fat" + explicit incremental = false for deterministic LTO without thin-parallelism drift. New .github/workflows/repro.yml builds dvb-warppool-daemon in two matrix jobs (a/b) on two fresh Linux x86_64 runners with identical SOURCE_DATE_EPOCH from commit timestamp and RUSTFLAGS=--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 via slsa-github-generator has 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.pub in the repo root, SHA256SUMS.bundle). Keyless-OIDC artifacts exist only for releases ≤ v1.15.0.
  • SBOM (Syft) — shipped, sbom.spdx.json in every release via anchore/sbom-action.
  • 8c Auto-update foundation (THIS SESSION) — New crate warppool-autoupdate with 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 in dvb-warppool-cli: check-update [--repo X] (HTTP fetch + version compare against CARGO_PKG_VERSION + asset list; --json for scripting), download-update --sha256 X --to PATH [--asset Y] (auto-asset selection via host_asset_substring() for linux-x86_64/-aarch64/macos-x86_64/-aarch64/windows-x86_64, otherwise manual --asset override; 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 in warppool-api: GET /api/admin/update-check (HTTP fetch latest + compare against CARGO_PKG_VERSION; response incl. assets + newer flag; audit update.check), POST /api/admin/update {asset, sha256, target_path, backup_path?} (download_verified + atomic_swap from inside the daemon process; no restart — restart_hint field shows systemctl restart dvb-warppool; audit update.applied/update.failed with details). Both 503 when checker=None. Daemon reads WARPPOOL_AUTOUPDATE_REPO env 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 SSE update_available event).
  • 8e Periodic auto-check + SSE event (THIS SESSION) — New PoolEvent variant UpdateAvailable{at, current, latest, name, prerelease} with snake_case type tag update_available. Daemon spawns periodic background task when WARPPOOL_AUTOUPDATE_REPO AND WARPPOOL_AUTOUPDATE_INTERVAL_HOURS > 0 (default 24, 0=disabled): every N hours fetch_latest + is_newer_than(CARGO_PKG_VERSION) → on newer release bus.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.service extended with StartLimitInterval=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 — uses ls -1t instead of GNU-only find -printf): finds newest backup in $WARPPOOL_BACKUP_DIR (default /var/lib/dvb-warppool/backup), atomic install via install -m 755 + mv -f (chown implicit since running as root), moves consumed backup to .applied-<timestamp> (prevents restart loop), systemctl restart --no-block so 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 var WARPPOOL_COSIGN_BIN must be set when cosign_verify=true — otherwise 500 (NOT silent skip, so the operator doesn’t get false security). Cosign exit≠0 → 403 + audit update.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 via actions/deploy-pages@v4 to 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/book runs cleanly, all 9 includes resolve.