Introduction

dvb-WarpPool is a Rust Bitcoin solo mining pool, written as a modern parallel build to dvb-goPool. 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-keyless), 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, 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.0.6 (2026-06-06). The phase table below covers the development history that led up to v1.0; post-1.0 changes are tracked in CHANGELOG.md.

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 TDP wiring (phase 7.6b)⏸ deferred (Bitcoin Core IPC since v30)
Phase 8 — Polishing (mdBook, repro builds, auto-update + rollback + Cosign)
Phase 9 — Setup health-checks (multi-RPC, network, probes with consent)
Phase 10a — bitcoin.conf snippet generator
Phase 11 — UPnP port forwarding (whitelist + consent)
Phase 12 — bitcoin.conf parse-existing (smarter snippets)
Phase 13 — Daemon periodic Bitcoin health-check + SSE
Phase 14 — UI HealthBanner + UpdateBanner
Phase 15 — Notifier fully wired + Email/Slack + admin-UI test buttons
└─ 15.2b Sv2 connection notifier wiring
Phase 16 — Observability (PoolMetrics + RPC latency histogram + notifier counter)
Phase 17 — Payout system⏭ skipped (single-user solo doesn't need it; coinbase splits cover multi-user)
Phase 18 — mdBook operator guide + ARCHITECTURE/SECURITY refresh
Phase 19 — Performance benchmarks (Criterion)
Phase 20 — Live energy + health trend
└─ 20.1 Time-series storage + power-tariff schema (Single/TOU/Solar)
└─ 20.2 Energy + history endpoints
└─ 20.3 Health anomaly detector (5 heuristics)
└─ 20.3b HealthAlert notifier hook (critical → ntfy/Discord/Slack/Email/Telegram)
└─ 20.4 UI EnergyCard in dashboard
└─ 20.4b UI per-miner detail page with sparklines
└─ 20.5 Solar HA provider (Home Assistant REST API)
Phase 21 — VAPID Web Push
└─ 21.1 Pure-Rust VAPID crypto + CLI gen-vapid-keys
└─ 21.2 Subscribe + public-key API (4 routes)
└─ 21.3 Daemon push-send loop (BlockFound/Health/Update → background push)
└─ 21.4 UI subscribe flow + service worker
Phase 22 — Vendor probe metrics
└─ 22.1 Per-miner Prometheus metrics
└─ 22.2 Discovered-miners auto-probing (env-gated)
└─ 22.4 AvalonQ probe adapter
└─ 22.5 Braiins OS probe adapter
Phase 23 — Probe hardening⏭ skipped (low-value: probes are LAN-local, probe_health gauge already covers failure detection)

Recognized miner vendors (8): Bitaxe / NerdNOS / NerdOctaxe / BitMain-Stock / Whatsminer / Avalon (A12xx-A15xx) / Avalon Q / Braiins OS / OS+.

Notifier sinks (6): ntfy / Telegram / Discord / Slack / Email-SMTP / VAPID Web Push (PWA background).

Tests (as of v1.0.6): 711 Rust tests green + 3 ignored (regtest opt-in). 347 svelte-check files / 0 errors / 14 pre-existing warnings.

Workspace: 16 library crates + 6 binaries (daemon, CLI, setup wizard, translator sidecar, miner simulator, macOS launcher). Single workspace, no external sub-repo.

What's still open:

  • Phase 7.6b — TDP wiring deferred (ecosystem re-eval trigger: Bitcoin Core IPC stable or SRI production-mature)
  • Phase 25 — Logos/icons for the native installer (design-asset topic)

Who builds the pool

dvb-WarpPool is a solo project by dvb-Projekt (dvbprojekt@gmx.de). Code is dual-licensed under MIT OR Apache-2.0. Contributions welcome — see the README for the disclosure policy.

Where to find what

Operator Handbook

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: github.com/dvb-projekt/dvb-WarpPool
  • Release pipeline + SBOM: .github/workflows/release.yml
  • Bench workflow: .github/workflows/benches.yml
  • Repro-verify: scripts/verify-reproducible.sh
  • Regtest setup: scripts/regtest-up.sh + regtest-down.sh
  • Sister project (Go implementation): dvb-goPool

Getting Started

Three paths to a running pool:

  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.

Docker

docker run -d \
  --name warppool \
  --restart unless-stopped \
  -p 18334:18334 \
  -p 3333:3333 \
  -v ~/.warppool:/var/lib/warppool \
  ghcr.io/dvb/dvb-warppool:latest

First call of the setup wizard: http://localhost:8331 (auto-detects hardware, suggests a profile, tests the Bitcoin RPC).

Native Binary

Raspberry Pi 5 (aarch64)

# arm64 .deb
wget https://github.com/dvb-projekt/dvb-WarpPool/releases/latest/download/dvb-warppool_arm64.deb
sudo dpkg -i dvb-warppool_arm64.deb
sudo systemctl enable --now dvb-warppool

Storage recommendation: NVMe-HAT > USB-3 SSD > industrial SD. The SQLite WAL plus shares_raw eviction is hard on SD cards under sustained pool load.

macOS / Linux x86_64

Tarball/pkg from the GitHub releases. The macOS .dmg is unsigned (notarization is queued for phase 8b); run xattr -dr com.apple.quarantine /Applications/dvb-WarpPool.app after mounting.

From Source

git clone https://github.com/dvb-projekt/dvb-WarpPool
cd dvb-WarpPool
cargo build --release --workspace
./target/release/dvb-warppool-setup     # First-Run Wizard auf :8333

The cargo target directory lands in ~/.cache/dvb-WarpPool/target (see .cargo/config.toml) so that iCloud sync doesn't trash the build directory.

Stratum V1 vs V2

The pool starts both listeners in parallel by default:

  • V1 plain (:3333) — all existing miner firmwares
  • V1 TLS (:3334) — if stratum_tls_listen + cert/key are set in config.toml
  • V2 NOISE (:34254) — if sv2_listen + sv2_static_priv_key_hex are set in secrets.toml

For V1-miner sidecars (e.g. when the pool should expose only Sv2 externally): the bundled dvb-warppool-translator binary. See Phase History → 7.4.

First Block

  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.

Next Steps

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:

    cd ~/code/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.


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 (you have a 64-connection cap, no per-IP limiting)
  • Authentication beyond bech32 address format (anyone with your hostname can point a miner at you and submit shares — to their own address)
  • Worker IP privacy (visible in your admin panel + logs)

For 5-10 friends this is fine. If your DuckDNS hostname leaks publicly and random people start mining to your pool, your only recourse is changing the hostname. For real abuse resistance you need tier 3 — see scaling.md.


Cheat sheet

# Initial setup (one time)
cd ~/code/dvb-WarpPool && ./scripts/setup-tls-public.sh

# Daemon restart (after each cert renewal)
pkill -f dvb-warppool-daemon ; sleep 2 ; open /Applications/dvb-WarpPool.app

# Force a renewal
~/.acme.sh/acme.sh --renew --domain warppool-oliver.duckdns.org --force

# Check cert status
echo | openssl s_client -connect warppool-oliver.duckdns.org:3334 \
  -servername warppool-oliver.duckdns.org 2>&1 \
  | openssl x509 -noout -dates -subject -issuer

# DNS + port reachability from outside
dig +short warppool-oliver.duckdns.org @8.8.8.8
nc -zv warppool-oliver.duckdns.org 3334

Good luck!

Scaling dvb-WarpPool

dvb-WarpPool is an open-source solo mining pool with operator UX. The codebase covers everything you need to mine to your own wallet with your own hardware, including for a small group of trusted friends. Beyond that, public pool operations introduce a different class of requirements (DDoS, reward sharing, multi-admin, compliance) that the OSS distribution does not address.

This document defines the four deployment tiers, what the OSS stack covers, and where commercial extensions begin.

Tier Matrix

TierUse caseOSS sufficient?Profile mapping
SoloOne operator, own miners on LANYesKlein
Solo + Friends5-10 miners, trust-based, LAN or InternetYesMittel
Community Pool50-100 miners, Discord-vettedPartially — needs extensionsGross
Public ServicePublicly advertised, 500+ minersNo — requires commercial stackEnterprise

What the OSS stack ships

Applies to all tiers:

  • Stratum-V1 plain + TLS, Stratum-V2 NOISE
  • Bitcoin Core solo mining (full reward to the block finder)
  • Class-aware VarDiff (Bitaxe / Antminer / Avalon Q / Avalon ASIC / NerdMiner / NerdNOS / Whatsminer)
  • Admin UI with Argon2 passwords, optional 2FA, signed auto-update channel
  • Live miner telemetry probes (AxeOS, MM-Summary for Avalon, WantClue NerdNOS, NerdMiner V2)
  • Energy tracking (live / 24h / 7d / lifetime with reset)
  • Hashrate chart with best-share overlay + 24h aggregation + Week/Month/Year view from daily aggregates
  • Notifier hooks (email, Web Push) for block-found events
  • Distribution: macOS .dmg, Linux .deb / .rpm / AppImage, Windows .msi, Docker multi-arch, Umbrel community store

For Solo and Solo + Friends that stack is production-ready. The only external steps for the friends tier are:

  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 — Community Pool gaps

GapWhy it matters at this scale
Reverse proxy with rate-limit + access-logOSS binds TLS directly in the daemon; Caddy/Nginx in front enables per-IP limits, easier cert rotation, and a single audit log
Per-IP connection limitGlobal connection-cap=64 is trivially exhausted by one attacker holding 64 idle TCP connections
Worker IP / topology disclosure gating/api/workers and daemon logs expose peer IPs unauthenticated — fine for a personal pool, a privacy issue if anyone untrusted can read the dashboard
Abuse-pattern detectionA worker that only submits invalid shares thrashes VarDiff and burns CPU on validation; needs auto-ban on > N% rejected
Automated DB backupAdmin restore card exists for config, but warppool.db snapshotting is manual
Stratum whitelist / registrationOSS accepts any bech32 address — operators of a real community pool usually want to register members

Tier 4 — Public Service gaps (in addition to Tier 3)

GapWhy it matters at this scale
DDoS mitigationCloudflare TCP pass-through or dedicated providers; connection-cap alone is not protection
PPLNS / FPPS payout engineOSS is pure solo (100% of the reward to the finder); a publicly advertised pool without reward sharing will not attract miners
Multi-admin + role-based accessSingle admin user; production needs read-only roles and per-user audit
T&C / Privacy templatesEU operators need GDPR-compliant worker-IP processing notice; liability if a found block is lost
Public stats pageAnonymous view of hashrate / active miners / block history for prospective miners
Block-found webhooksDiscord / Telegram integrations are not in OSS
Block explorer linkingEvery block in the UI should link to mempool.space
Monitoring stack/metrics is exposed (Prometheus format), but a real scrape config + alerting beyond the local notifier + Grafana dashboards are operator-supplied
Sentry / error trackingDaemon panics go to the tracing log only
SLA / maintenance contract24/7 on-call is a hard requirement for an advertised public pool
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 a GitHub issue in this repo:

  • Tier 3 (Community Pool): label community-pool
  • Tier 4 (Public Service): label enterprise-inquiry

Please include in the issue body:

  • Use case (private / community / commercial)
  • Expected number of miners
  • Hosting setup (own server / cloud / Umbrel / Mac / etc.)
  • Timeline + budget range
  • Specific items from the gap list above that you need addressed

Response time: typically 2-5 business days. You will receive a fixed-price proposal (Tier 4) or an hour estimate (Tier 3).

Why this split exists

The OSS distribution is what one person + a small trusted group can run safely themselves. Anything beyond that requires ongoing operations work — DDoS response, payout reconciliation, compliance updates, user support — that does not belong in a publicly forkable codebase. Splitting it this way keeps the core honest about its scope, and lets the project sustain itself if anyone wants to run it at scale.

Configuration Reference

dvb-WarpPool has two configuration files plus a set of env vars for optional subsystems:

  • config.toml — all non-sensitive settings (pool behavior, Stratum, VarDiff, notifier schema)
  • secrets.toml — auth hashes, RPC credentials, Sv2 keys; always chmod 600
  • env vars — opt-in subsystems (auto-update, health-check interval, notifier secrets, debounce tuning)

Defaults are defined in crates/config/src/lib.rs; this page is the operator's view.

config.toml

[branding]

[branding]
status_brand_name = "dvb-WarpPool"
server_location = "self-hosted"
fiat_currency = "EUR"
pool_donation_address = ""

Controls the strings shown in the UI and in the Prometheus build_info metric. No effect on functionality.

[logging]

[logging]
debug = false
net_debug = false
json = true        # JSON-formatted for Loki/Promtail; set false for readable stdout

Runtime override via env var: RUST_LOG=warppool=debug takes precedence over the config setting.

[mining]

[mining]
payout_address = "bc1q..."           # REQUIRED — block-reward recipient
pool_fee_percent = 0.0                # Default = no pool fee (solo)
pool_fee_address = ""                 # Required when fee_percent > 0
pooltag_prefix = "dvbprojekt-WarpPool"  # 19 chars max, written to coinbase scriptSig
operator_donation_address = ""
operator_donation_percent = 0.0

For multi-user-solo (hosting-service setup): set pool_fee_percent = 1.0 and pool_fee_address. The sum of fee + donation must not exceed 99%.

[node]

[node]
rpc_url = "http://127.0.0.1:8332"
zmq_hashblock_addr = "tcp://127.0.0.1:28332"
zmq_rawblock_addr = "tcp://127.0.0.1:28333"
rpc_cookie_path = "/home/bitcoin/.bitcoin/.cookie"   # optional, alternative to user/pass in secrets.toml

The cookie takes precedence over user/pass in secrets.toml. Leaving the ZMQ fields empty enables poll-only mode (slower but functional).

[server]

[server]
pool_listen = ":3333"
status_listen = ":18334"
status_tls_listen = ":18443"          # optional
status_public_url = "https://pool.example.com"   # for push notifications
trust_proxy_headers = false           # only set true when the daemon runs behind Caddy/nginx

status_public_url is required by the UI to build absolute URLs for web push and email links. Leave empty when not running behind a reverse proxy.

[stratum]

[stratum]
stratum_tls_listen = ":3443"          # optional, V1 TLS
stratum_password = ""                 # empty = address-format auth only
safe_mode = true                      # Conservative validator
tls_cert_path = "/etc/dvb-warppool/cert.pem"
tls_key_path = "/etc/dvb-warppool/key.pem"
sv2_listen = ":3334"                  # optional, Stratum V2
sv2_max_connections = 1024

Sv2 requires secrets.sv2_static_priv_key_hex. If it is missing, Sv2 is disabled with a warning (the daemon still starts).

[tuning]

[tuning]
max_saved_workers_per_user = 64
max_saved_workers_pool_total = 1024

Storage cap. Above these thresholds, old/idle workers are evicted (FIFO by last_seen_at).

[profile]

[profile]
kind = "klein"                  # klein | mittel | gross | enterprise; omit = auto
expected_miner_count = 5        # for auto: estimate used by auto-detection

The profile affects defaults and resource limits. Hot-switchable via POST /api/admin/profile — the config value is only the fallback at first start.

[vardiff]

[vardiff]
enabled = true
target_seconds_per_share = 30.0
window = 16
min_diff = 1.0
max_diff = 65536.0
retarget_after_n_shares = 8
hysteresis = 0.30          # 30% — a new diff that deviates > 30% from the old one triggers a retarget
max_step = 4.0             # max 4× per retarget
initial_diff = 1.0

EMA-based, with persisted snapshots in the vardiff_state table. Tests: crates/stratum-v1/src/vardiff.rs.

[ratelimit]

[ratelimit]
enabled = true
connects_per_sec = 5.0     # per peer IP
connect_burst = 20
auths_per_sec = 1.0
auth_burst = 10
idle_evict_secs = 300      # after 5 min without traffic → bucket state is evicted

Per-peer-IP token bucket. Triggered limits return mining.authorize with result: false (no disconnect — the miner should not get stuck in a reconnect loop).

[notifier]

See Notifications for detailed sink setup guides.

[notifier]
webpush_subscriptions_path = "/var/lib/dvb-warppool/webpush.json"

[notifier.ntfy]
topic_url = "https://ntfy.sh/my-pool-topic-secret"
on_block_found = true
on_miner_disconnect = false
on_rpc_down = true

[notifier.telegram]
bot_token_env = "TELEGRAM_BOT_TOKEN"
chat_id = "123456789"
on_block_found = true

[notifier.discord]
webhook_url_env = "DISCORD_WEBHOOK_URL"
on_block_found = true

[notifier.slack]
webhook_url_env = "SLACK_WEBHOOK_URL"
on_block_found = true
on_miner_disconnect = false
on_rpc_down = true

[notifier.email]
smtp_url = "smtps://pool@mail.example.com:465"
from = "pool@example.com"
to = ["operator@example.com"]
password_env = "POOL_SMTP_PASSWORD"
on_block_found = true
on_rpc_down = true

All sinks are optional. The daemon starts fine without a [notifier] section at all. Sinks whose env vars are missing are skipped with a notifier sink skipped warning.

secrets.toml (chmod 600)

rpc_user = "warppool"            # optional, alternative to cookie
rpc_pass = "..."                  # optional, ditto
admin_username = "admin"          # default "admin"
admin_password_hash = "$argon2id$..."   # via `dvb-warppool-cli hash-password`
jwt_secret = "..."                # min 32 bytes recommended; empty → admin auth disabled
sv2_static_priv_key_hex = "..."  # via `dvb-warppool-cli gen-sv2-key`

An empty admin_password_hash or empty jwt_secret makes every /api/admin/* endpoint return 503 "auth disabled". Useful for a read-only setup without an admin UI.

Phase 21: VAPID Web Push Secrets

vapid_public_key = "B..."      # via `dvb-warppool-cli gen-vapid-keys`
vapid_private_key = "..."      # same command
vapid_contact = "mailto:operator@example.org"   # optional, default mailto:operator@localhost

The public key is served via /api/push/vapid-public-key (public, no-auth — the UI needs it for PushManager.subscribe). The private key stays daemon-side only. If either value is empty, web push is disabled — the daemon log reports "web-push disabled (no vapid keys)".

Environment Variables

Bitcoin Health Check

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 GitHub for new releases. 0 = off.
WARPPOOL_COSIGN_BIN(unset)Path to the cosign binary for verify-blob. Required when cosign_verify=true in the update request.

Stratum / Notifier Tuning

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. Currently implemented: Home Assistant via its REST API.

[mining.electricity.solar]
enabled = true
kind = "home_assistant"
url_env = "WARPPOOL_HA_URL"            # e.g. http://homeassistant.local:8123
token_env = "WARPPOOL_HA_TOKEN"        # Long-Lived Access Token
pv_entity_id = "sensor.pv_power_total"
consumption_entity_id = "sensor.grid_load_total"   # optional
poll_interval_secs = 60                # 60s is enough to track PV trends
surplus_buffer_w = 200                 # safety buffer
excess_rate_eur_kwh = 0.0              # self-generated power = free
stale_after_secs = 300                 # fall back after 5 min without a snapshot

Create the token in HA: Profile → Long-Lived Access Tokens → Create Token. Entity IDs are listed under Developer Tools → States.

If consumption_entity_id is omitted, the pool compares the PV value directly against its own consumption (not the whole-house total).

/api/energy reports current_rate_source = "solar-excess" and a solar object containing pv_w / consumption_w / excess_w / age_seconds.

Vendor Probes (Phase 22)

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.

CLI Override

dvb-warppool-daemon --help lists the CLI flags that can override individual config values. Common ones:

dvb-warppool-daemon \
  --config /etc/dvb-warppool/config.toml \
  --secrets /etc/dvb-warppool/secrets.toml \
  --db-url sqlite:///var/lib/dvb-warppool/pool.db \
  --ui-dir /usr/share/dvb-warppool/ui \
  --no-zmq                          # debug-only: force poll-only mode

Reload Behavior

There is currently no hot reload for config.toml. Restart the daemon after every change:

systemctl restart dvb-warppool

The only hot-switchable items are:

  • Profile (POST /api/admin/profile) — affects display and defaults, not the max_connections semaphore
  • 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 full diagnostic of:

  1. Bitcoin Core health — everything reachable over RPC
  2. Local pool network — ports, UPnP gateway
  3. External reachability — optional, opt-in, with consent

Every check has a clear privacy model.

Privacy matrix

CheckWhat it doesPrivacy
Bitcoin health (RPC calls)Localhost → Bitcoin Core✅ Local
Port-bind smoke 3333/3334/34254tokio::TcpListener::bind locally✅ Local
UPnP/NAT-PMP discoveryUDP multicast on the LAN✅ LAN-only
IP echo via api.ipify.orgHTTPS GET → 3rd party sees your IP⚠️ Opt-in
Port 8333 probe via bitnodes.ioPings <IP>:8333⚠️ Opt-in
Stratum port guide URLsWe send nothing — we only return 3rd-party URLs for manual testing✅ Local (operator contacts the 3rd party themselves in the browser)

Each opt-in check has its own separate checkbox with explicit consent text. Default: all off. Clicking "Run" without consent enabled → alert + no request.

Bitcoin health in detail

A click on "Bitcoin Core health check" makes 5 RPC calls in parallel:

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

Local network

A click on "Local check (ports + UPnP)" does:

  1. Port-bind smoke for 3333 (V1 plain), 3334 (V1 TLS), 34254 (Sv2 NOISE). We briefly bind on 0.0.0.0:<port> and drop immediately. That tells us whether something else is already running there (a parallel gopool, an old daemon, a port conflict).
  2. UPnP discovery via the igd crate (pure-Rust, no avahi/libupnp dependency). Returns the gateway IP + external IP if a UPnP-capable router is on the LAN.

The UPnP result is cached internally → the next opt-in probe automatically uses the external IP without an extra api.ipify.org call.

Three opt-in checks:

1. IP echo (api.ipify.org)

Fetches your public IP via HTTPS. You only need this when UPnP doesn't work (no UPnP-capable router) and you still need the external IP to run the other probes or to give miners a connect address.

2. Port 8333 via bitnodes.io

Bitnodes.io is a public Bitcoin node crawler. We do a GET on https://bitnodes.io/api/v1/nodes/<IP>-8333/. If bitnodes knows your node (404 = unknown): probably fine. If not: either the port isn't open or the node is still too new.

Side effect: your node will then appear publicly on https://bitnodes.io as a known Bitcoin node. That's desired information for any pool operator — other nodes can connect to yours.

3. Stratum port guide

We do NOT scan from the outside, because that would make us a scanning tool ourselves. Instead, we generate a list of 3rd-party tools with your IP + port number pre-filled for a manual self-test:

  • yougetsignal.com port checker (direct link with ?remoteAddress=…&portNumber=…)
  • canyouseeme.org (manual port entry, because we haven't pinned down their URL format)

The operator clicks the links in a new tab — we send nothing, nobody contacts themselves automatically.

What this looks like in code

Backend in apps/dvb-warppool-setup/src/main.rs:

  • bitcoin_health(BitcoinHealthReq) — the 5 RPC calls + collect_bitcoin_warnings
  • network_health_local() — port-bind + UPnP via spawn_blocking
  • external_probe(ExternalProbeReq) — 422 without consent, otherwise dispatched

The aggregation logic is extracted as a pure function collect_bitcoin_warnings() and has 8 unit tests for every warning edge case (IBD, low-peers, no-inbound, no-zmq, pruned, verification-progress, no-double-warning-on-IBD, healthy-zero-warnings).

UI in apps/dvb-warppool-setup/src/index.html:

  • Bitcoin card with RPC test + health-check button
  • Network card with local button + probe rows (checkbox + button per probe)
  • Status cards with icons (✅⚠️❌), key-value rows, warning list with ⚠ bullet

Phase 10a — bitcoin.conf snippet generator

Instead of just emitting warnings, since Phase 10a the health check generates a copy-paste-ready bitcoin.conf snippet that fixes the identified problems. No direct write — the operator decides what (if anything) to adopt.

What ends up in the snippet

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

Phase 11 — UPnP port forwarding

Instead of just discovering the UPnP gateway, since Phase 11 the setup wizard can also set port mappings directly — via igd::add_port exposed as an HTTP endpoint. With a whitelist, consent gate, and a 1h lease as default (no permanent default).

Endpoints

POST /api/network-upnp-forward with body:

{
  "port": 3333,
  "protocol": "tcp",
  "description": "dvb-WarpPool Stratum V1",
  "consent": true,
  "lease_seconds": 3600
}

POST /api/network-upnp-remove to remove (same {port, protocol, consent} pattern).

Security gates

LayerWhat gets checked
Consentconsent=true must be in the body — otherwise 422. A router state change is invasive, so the operator must actively agree.
Port whitelistOnly 8333 (Bitcoin P2P), 3333, 3334, 34254 (Stratum V1/TLS/V2). Other ports → 400 + list of allowed ports in the error. Prevents the wizard from being misused as a generic port opener.
Protocol whitelistOnly tcp/udp (case-insensitive). Others → 400.
Lease capDefault 3600s (1h). Max 86400s (24h, hard cap). The operator has to renew for longer mappings — prevents "fire-and-forget" mappings that stay open for years.
Local-IP detectionVia the UDP-connect trick (bind 0.0.0.0:0 + connect 8.8.8.8:80 → local_addr()). No extra dependency. IPv6 outbound → error (UPnP needs IPv4 NAT).

What we do NOT do

  • No persistent storage of the mappings we set — the wizard is one-shot. For persistent forwards with auto-renewal see Phase 27 ([upnp] block in config.toml).
  • No multi-router supportigd::search_gateway picks the first UPnP-capable gateway on the LAN. With mesh setups using two routers, only one of them gets hit.

Phase 27 — renew loop in the daemon

The setup wizard opens a port once with a 1h lease. If the pool runs longer, the lease has to be extended — otherwise the router drops the forward. Phase 27 adds a renew loop in the daemon that refreshes the forwards listed in the [upnp] block every renew_interval_secs. Opt-in (default enabled = false).

Configuration:

[upnp]
enabled = true
renew_interval_secs = 3000  # 50min, safely below the 3600s lease

[[upnp.forwards]]
port = 3333
protocol = "tcp"
lease_seconds = 3600
description = "Stratum V1"

UPnP add_port is idempotent on the router side (same port + protocol = extend lease, don't create a second entry). The first renew happens 10s after daemon start (covers crash recovery), then every renew_interval_secs seconds (range 60..=86400). Errors are only logged — the loop keeps running so transient problems don't permanently kill the forward.

Typical router quirks

  • FritzBox default: UPnP-discoverable = yes, but add_port = blocked. The operator has to enable "Allow UPnP status changes via UPnP" in the web UI. We then 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"
  ]
}

The UI subscribes via EventSource('/api/events'), dispatches on event.type === 'health_snapshot', and renders a banner/badge with the warnings array.

What it deliberately does NOT do

  • No bitcoin.conf parse in the periodic check — that's setup-wizard-specific because it generates snippet recommendations. The daemon doesn't need it.
  • No UPnP check in 13b — UPnP state changes rarely and is operator-side. The setup wizard checks once.
  • No delta detection in 13b — we push every snapshot; the UI does the client-side compare if needed. Saves server state.
  • No API endpoint GET /api/admin/health in 13b — the UI uses SSE. If needed for CLI scripting, that would be a Phase 13c addition.

Operator example

# Live SSE-Stream auf health-events filtern
curl -N http://localhost:18334/api/events 2>/dev/null \
    | grep -A1 'event: health_snapshot'

# event: health_snapshot
# data: {"type":"health_snapshot","at":"...","rpc_ok":true,...}

The operator sees, for example, when after weeks of uptime zmq_hashblock_ok=false suddenly shows up (Bitcoin Core restarted without ZMQ config) — and can react without anyone having to monitor hands-on.

What Phase 14 could bring

  • UI dashboard widget for the health_snapshot events (banner badge with warning count, click → expanded detail view)
  • Delta detection in the daemon: only push events when warnings have changed (instead of every tick → saves event-bus traffic on unchanged state)
  • GET /api/admin/health for CLI scripts that check on demand without opening an SSE stream

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

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 five event types:

EventWhenPriority
block-foundAfter a successful submitblock in the daemonhigh
miner-disconnectAn authenticated worker closes the Stratum connection; per-worker debounce 30smedium
rpc-downHealth check first hits RPC=failmedium
rpc-recoveredHealth check first hits RPC=ok again after a down statelow
testOperator clicks the test button in the admin UIlow

Each sink can toggle which events it cares about (on_block_found, on_miner_disconnect, on_rpc_down — all bool). The default everywhere is on_block_found = true, others off; for Slack/Email all three are toggleable.

Retry: 3 attempts with exponential backoff (2s / 10s / 30s). If everything fails, it gets logged but the daemon keeps running — a notifier failure must never disrupt mining.

ntfy.sh

Simple and account-free. A single topic URL is enough.

[notifier.ntfy]
topic_url = "https://ntfy.sh/my-secret-pool-topic-abc123"
on_block_found = true
on_miner_disconnect = false
on_rpc_down = true

On your phone, install the ntfy app, subscribe to the same topic name, done. Topic names are effectively a shared secret — choose a hard-to-guess string.

For self-hosted ntfy, just use topic_url = "https://ntfy.your-domain.com/topic".

Telegram

Create a bot via @BotFather → note the token. You get a chat_id by sending the bot a message and then fetching https://api.telegram.org/bot<TOKEN>/getUpdates (look for "chat":{"id":…}).

[notifier.telegram]
bot_token_env = "TELEGRAM_BOT_TOKEN"
chat_id = "123456789"
on_block_found = true

Then in the daemon environment:

TELEGRAM_BOT_TOKEN="123:abc..." dvb-warppool-daemon

With systemd: Environment="TELEGRAM_BOT_TOKEN=…" in the unit file, or better an EnvironmentFile=/etc/default/dvb-warppool with chmod 600.

Discord

In the server settings under Integrations → Webhooks, create a new webhook and copy the URL. The URL is effectively the auth token — anyone who has it can post.

[notifier.discord]
webhook_url_env = "DISCORD_WEBHOOK_URL"
on_block_found = true
DISCORD_WEBHOOK_URL="https://discord.com/api/webhooks/…" dvb-warppool-daemon

Slack

Create a Slack app in your workspace → enable Incoming Webhooks → generate a webhook for a channel. The payload uses Block Kit (header + section with mrkdwn) for clean layout.

[notifier.slack]
webhook_url_env = "SLACK_WEBHOOK_URL"
on_block_found = true
on_miner_disconnect = false
on_rpc_down = true

Email (SMTP)

lettre-based, with rustls — no openssl-sys. Two URL schemes:

  • smtps://user@host:465 — implicit TLS (common with providers like Posteo, Migadu, Fastmail, Hetzner)
  • smtp://user@host:587 — STARTTLS (gmail, many mail servers)
[notifier.email]
smtp_url = "smtps://pool@mail.example.com:465"
from = "pool@example.com"
to = ["operator@example.com", "monitoring@example.com"]
password_env = "POOL_SMTP_PASSWORD"
on_block_found = true
on_miner_disconnect = false
on_rpc_down = true
POOL_SMTP_PASSWORD="…" dvb-warppool-daemon

password_env is optional — if omitted, no SMTP AUTH is attempted (useful for a local MTA like Postfix on localhost:25 that relays internally).

With multiple recipients, each one gets their own letter — also a simple way to notify the whole team at once.

Test workflow

After configuration:

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

Other Phase B options

  • Matrix — not implemented; PR welcome, analogous to the Slack sink pattern.
  • PagerDuty / Opsgenie — same; a generic webhook sink with HMAC signing would solve this without vendor lock-in.

See also

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

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_bitcoin_rpc_latency_secondshistogramEnd-to-end RPC call duration (all retries included)

Histogram buckets (seconds): 0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, +Inf. Prometheus cumulative semantics — each observation increments every bucket ≥ its value.

Example query (Grafana):

# p99 RPC latency, last 5 minutes
histogram_quantile(0.99, rate(warppool_bitcoin_rpc_latency_seconds_bucket[5m]))

# RPC call rate
rate(warppool_bitcoin_rpc_latency_seconds_count[1m])

Phase 22: per-miner vendor probe metrics

When the daemon's miner_poll_loop is running (default), configured miners are polled every 30s and their telemetry values are exposed as gauges:

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

Example query: sink failure rate (a hint at wrong env vars or blocked webhooks):

rate(warppool_notifier_events_sent_total{result="err"}[5m])
  / ignoring(result) group_left
  rate(warppool_notifier_events_sent_total[5m])

Grafana Dashboard

A starter dashboard with the most important panels:

{
  "title": "dvb-WarpPool",
  "panels": [
    {
      "title": "Blocks Found",
      "type": "stat",
      "targets": [{ "expr": "warppool_blocks_found_total" }]
    },
    {
      "title": "Hashrate (approx, last 5min)",
      "type": "timeseries",
      "targets": [{
        "expr": "rate(warppool_shares_accepted_total[5m]) * pow(2, 32)"
      }]
    },
    {
      "title": "Active Connections",
      "type": "timeseries",
      "targets": [
        { "expr": "warppool_active_connections{protocol=\"v1\"}", "legendFormat": "v1" },
        { "expr": "warppool_active_connections{protocol=\"v2\"}", "legendFormat": "v2" }
      ]
    },
    {
      "title": "RPC Latency (p50/p99)",
      "type": "timeseries",
      "targets": [
        { "expr": "histogram_quantile(0.50, rate(warppool_bitcoin_rpc_latency_seconds_bucket[5m]))", "legendFormat": "p50" },
        { "expr": "histogram_quantile(0.99, rate(warppool_bitcoin_rpc_latency_seconds_bucket[5m]))", "legendFormat": "p99" }
      ]
    },
    {
      "title": "Bitcoin Core Health",
      "type": "stat",
      "targets": [
        { "expr": "warppool_rpc_ready" },
        { "expr": "warppool_rpc_ibd" }
      ]
    }
  ]
}

(A full dashboard with variables and annotations may follow later as packaging/grafana/dashboard.json.)

Prometheus scrape config

scrape_configs:
  - job_name: dvb-warppool
    scrape_interval: 15s
    static_configs:
      - targets: ['pool.local:18334']

Alert recipes

RPC unreachable > 2min

- alert: WarppoolRpcDown
  expr: warppool_rpc_ready == 0
  for: 2m
  annotations:
    summary: "Pool {{ $labels.instance }} has no RPC connection to the Bitcoin node"

No shares > 10min (miner offline?)

- alert: WarppoolNoShares
  expr: rate(warppool_shares_accepted_total[10m]) == 0
  for: 10m
  annotations:
    summary: "Pool {{ $labels.instance }} is receiving no shares"

RPC latency p99 > 1s

- alert: WarppoolRpcSlow
  expr: |
    histogram_quantile(0.99,
      rate(warppool_bitcoin_rpc_latency_seconds_bucket[5m])
    ) > 1
  for: 5m

Notifier sink failing persistently

- alert: WarppoolNotifierBroken
  expr: |
    rate(warppool_notifier_events_sent_total{result="err"}[15m])
      / ignoring(result) group_left
      rate(warppool_notifier_events_sent_total[15m]) > 0.5
  for: 15m
  annotations:
    summary: "Notifier sink {{ $labels.sink }} failing >50% — check config"

SSE Events (separate story)

Alongside /metrics, /api/events runs a Server-Sent-Events stream that pushes live events to the UI (block_found, new_job, shares_accepted, ...). It's primarily intended for the UI banners; for monitoring, use /metrics — Prometheus is more robust against scraping pauses.

See also

Troubleshooting

A pragmatic fix-guide for the most common problems. For each symptom: brief explanation → diagnostics → fix.

General: reading logs

journalctl -u dvb-warppool -f --since "5 min ago"

For Docker:

docker logs -f dvb-warppool --since 5m

Temporarily raise the log level:

RUST_LOG=warppool=debug,info dvb-warppool-daemon

Targeted per crate:

RUST_LOG=warppool_stratum_v1=trace,warppool_bitcoin_rpc=debug,info dvb-warppool-daemon

RPC

getblockchaininfo failed: HTTP 401 — credentials rejected

Cookie or user/pass mismatch.

Diagnosis:

# Cookie auth:
cat ~/.bitcoin/.cookie       # should be "user:hexhash"
# user/pass auth:
grep -E "^rpc(user|password)" ~/.bitcoin/bitcoin.conf

Fix:

  • Cookie mode: make sure node.rpc_cookie_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

Log line at startup:

zmq watcher: WARPPOOL_ZMQ_HASHBLOCK_ADDR not set, falling back to poll-only

Fix: set in config.toml:

[node]
zmq_hashblock_addr = "tcp://127.0.0.1:28332"

And in bitcoin.conf:

zmqpubhashblock=tcp://127.0.0.1:28332

Bitcoin Core must be restarted after this change.

ZMQ connection refused / timeout

Bitcoin Core isn't listening on the ZMQ port. Diagnosis:

ss -tlnp | grep 28332
# Expected output: bitcoind listens on 0.0.0.0:28332 or 127.0.0.1:28332

If nothing: the bitcoin.conf line is missing or Bitcoin Core wasn't restarted.

If 0.0.0.0: ZMQ is exposed — often necessary with a Docker bridge (daemon and bitcoind in different containers). On a direct host tcp://127.0.0.1:28332 should be enough.

Stratum / miner connections

Workers reconnect-loop every few seconds

Symptom: many session ended with shares_accepted = 0 in the logs. Many miner-disconnect events in the notifier (or, with Phase 15 debounce, one every 30s).

Common causes:

  • Wrong worker address: a V1 miner submits an address the ShareValidator doesn't accept. Logs show mining.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.

Notifier doesn't fire

notifier sink skipped: env var foo not set

At daemon start, the env vars for the sink aren't present. With systemd:

[Service]
EnvironmentFile=/etc/default/dvb-warppool

With /etc/default/dvb-warppool as a chmod-600 file:

TELEGRAM_BOT_TOKEN=123:abc...
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
POOL_SMTP_PASSWORD=...

For Docker: via -e KEY=val or an env-file in docker-compose.yml.

Test button shows err

Click Test in the admin UI → err badge. The specific error shows as a tooltip on the badge (hover). Common cases:

  • Telegram: bot not found — wrong bot_token. chat not found — wrong chat_id or the bot was removed from the chat.
  • Discord: Unknown Webhook — webhook was deleted.
  • Slack: invalid_payload — very rare; usually a typo in the webhook URL.
  • Email: Could not resolve host — wrong SMTP host. authentication failed — wrong password env. unable to connect: connection refused — wrong port or firewall.

Spam from miner-disconnect events

Per-worker debounce is 30s (WARPPOOL_DISCONNECT_DEBOUNCE_SECS). If that's too noisy: set the env var to 300 (5min).

If a single worker keeps disconnecting: that's a real problem on the miner side — check the logs on the miner hardware (the Bitaxe web interface has Logs; Antminer has SSH access).

Auto-update

Update fails: download/verify: sha256 mismatch

The asset on GitHub was changed between when you saw the release and downloaded it (rare), or you passed the wrong sha256. For POST /api/admin/update, always take the sha256 from the release notes file or from the GitHub API itself — never type it manually.

Update applied but daemon keeps restarting (StartLimitBurst)

The Phase 8f rollback hook fired. The systemd journal shows:

OnFailure=dvb-warppool-rollback.service triggered

rollback.sh copies the .backup back and runs systemctl restart. If the backup is also broken: install the old version manually or roll back from a snapshot.

cosign verify-blob failed

When cosign_verify=true is set in the update request: the signature doesn't match the asset. Possible causes:

  • The asset is authentic but the signature file is stale
  • The asset has been tampered with

The default is cosign_verify=false — only use cosign if you've explicitly configured the trust anchor (public key or Sigstore root).

Setup wizard

Most likely a permission issue. The setup wizard shows the exact path + suggested fix:

sudo usermod -a -G bitcoin warppool   # one-time
sudo chmod g+r ~/.bitcoin/.cookie     # after every Core restart

Cleaner: configure bitcoind with rpcauth=warppool:saltedhash and use user/pass mode instead of the cookie.

UPnP test "no gateway found"

The router has UPnP off, or the daemon is bound to the wrong interface. Some ISP routers (Vodafone, some FritzBoxes with a locked-down user account) don't allow UPnP from the outside.

Fix:

  • Enable UPnP in the router web interface
  • Alternatively: set port forwarding manually (3333/3334)

bitcoin.conf snippet suggests recommendations for keys that are already set

If the setup wizard says "add zmqpubhashblock=..." even though it's already there — usually the wrong conf path. Check bitcoin-cli -datadir=… getrpcinfo to see the datadir that's actually in use.

API / UI

/api/admin/* returns 401

Admin auth is configured but the token is missing from the request:

curl -H "Authorization: Bearer wpat_…" http://pool.local:18334/api/admin/notifier/sinks

Generate a token with dvb-warppool-cli token-create -n my-script — the raw token is shown only once.

/api/admin/* returns 503 "auth disabled"

secrets.toml has no admin_password_hash or jwt_secret. Fix:

dvb-warppool-cli hash-password

and paste the output into secrets.toml.

UI doesn't load — Failed to fetch /api/...

Direct --ui-dir mode or dev mode? In dev (pnpm dev), Vite runs with a proxy /api → http://127.0.0.1:18334. In production: the UI is served directly by the daemon via --ui-dir. Check the daemon startup log:

serving static UI from daemon ui_dir=/usr/share/dvb-warppool/ui

If not present: the --ui-dir flag is missing or the directory doesn't exist.

Vendor probes (Phase 22)

Miner is discovered via mDNS but no telemetry

Possible causes:

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/pool.db /var/lib/dvb-warppool/pool.db.broken
systemctl start dvb-warppool   # daemon creates a fresh DB and runs migrations

You will lose:

  • VarDiff snapshots per worker (every miner starts at initial_diff)
  • Aggregated hashrate history (raw shares > 1h are evicted anyway)
  • Audit log

Blocks-found is NOT the source of truth in the DB — those live in the Bitcoin blockchain itself. The block list is lazily re-populated on the first request via getblock RPCs.

See also

Packaging & Installation

dvb-WarpPool ships in multiple formats. The release pipeline (.github/workflows/release.yml) builds all artifacts listed below in parallel on every v* tag and signs them with Cosign keyless OIDC.

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

  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 + arm64ghcr.io/<org>/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⚠ unsignedPhase B: Notarization (Dev-ID)
.msix64⚠ unsignedPhase B: code-signing certificate
TarballLinux + macOSManual installs
Umbrelamd64 + arm64packaging/umbrel/dvb-warppool/

Linux .deb (Debian / Ubuntu / Mint)

# download from GitHub Releases:
wget https://github.com/dvb-projekt/dvb-WarpPool/releases/download/v0.1.0/dvb-warppool_0.1.0-1_amd64.deb
sudo apt install ./dvb-warppool_0.1.0-1_amd64.deb

# first-run wizard (Bitcoin Core RPC, pool profile, payout address):
sudo dvb-warppool-setup

# start the daemon:
sudo systemctl enable --now dvb-warppool

# UI: http://localhost:18334

What the postinst does:

  • creates the system user warppool
  • creates /var/lib/dvb-warppool/ (data, mode 0700, owner warppool) and /var/log/dvb-warppool/
  • chgrp warppool /etc/dvb-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-0.1.0-1.x86_64.rpm
sudo dvb-warppool-setup
sudo systemctl enable --now dvb-warppool

post_install_script does the same as the .deb postinst (system user, directories, daemon-reload).

Linux AppImage (portable, no-root)

chmod +x dvb-WarpPool-0.1.0-x86_64.AppImage
./dvb-WarpPool-0.1.0-x86_64.AppImage

Also available as an aarch64 AppImage for ARM boards (Raspberry Pi 5, Pine64, Rock-Pi):

chmod +x dvb-WarpPool-0.1.0-aarch64.AppImage
./dvb-WarpPool-0.1.0-aarch64.AppImage

The first launch opens the setup wizard if no ~/.config/dvb-warppool/config.toml exists. After that the daemon starts directly and the UI is reachable at http://localhost:18334.

Raspberry Pi 5 (Raspberry Pi OS / Ubuntu 24.04 for arm64)

dvb-WarpPool runs very well on the Pi 5 — the Cortex-A76 cores have significantly better single-thread performance than the Pi 4 (relevant for Stratum connection handling and Bitcoin RPC). 8 GB RAM is enough for the gross profile with a few hundred workers; with 16 GB, enterprise is realistic.

Fastest path: .deb package

# Raspberry Pi OS / Ubuntu / Debian for arm64:
wget https://github.com/dvb-projekt/dvb-WarpPool/releases/download/v0.1.0/dvb-warppool_0.1.0-1_arm64.deb
sudo apt install ./dvb-warppool_0.1.0-1_arm64.deb
sudo dvb-warppool-setup
sudo systemctl enable --now dvb-warppool

Alternative: Docker (with Bitcoin Core sidecar)

docker run -d --name warppool \
  --restart unless-stopped \
  -p 3333:3333 -p 18334:18334 \
  -v /etc/dvb-warppool:/config:ro \
  -v warppool-data:/data \
  ghcr.io/dvb/dvb-warppool:v0.1.0

Multi-arch image — Docker automatically pulls the arm64 variant on the Pi.

Storage recommendation

  • NVMe HAT (e.g. Pimoroni NVMe Base, Geekworm X1003) strongly recommended. The pool DB (SQLite WAL + shares_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 as the feasible maximum, plus the NVMe storage hint as Severity::Info.

Pi 4 works too

The Pi 4 is tested, but the Pi 5 is recommended (higher per-core performance). Pi 3 and Pi Zero only make sense for klein profiles and are not intended for production use.

macOS .dmg

Unsigned — Gatekeeper warning on first launch. Notarization with an Apple Developer ID is planned for Phase B.

  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

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 \
  ghcr.io/dvb/dvb-warppool:v0.1.0 \
  --config /config/config.toml --data-dir /data

Multi-arch (amd64 + arm64) is selected automatically — no --platform needed.

Signature verification (Cosign)

Every release includes SHA256SUMS plus a Cosign keyless signature (SHA256SUMS.sig + SHA256SUMS.pem). Verification without locally stored keys (OIDC trust):

cosign verify-blob \
  --certificate SHA256SUMS.pem \
  --signature SHA256SUMS.sig \
  --certificate-identity-regexp "https://github.com/dvb-projekt/dvb-WarpPool/.github/workflows/release.yml@.*" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  SHA256SUMS

# then against the individual files:
sha256sum --check SHA256SUMS

SLSA build provenance

Every release ships with SLSA-3 provenance via slsa-github-generator. The provenance is in the release assets area (*.intoto.jsonl) and can be checked with slsa-verifier:

slsa-verifier verify-artifact \
  --provenance-path multiple.intoto.jsonl \
  --source-uri github.com/dvb-projekt/dvb-WarpPool \
  --source-tag v0.1.0 \
  dvb-warppool_0.1.0-1_amd64.deb

SBOM (SPDX)

sbom.spdx.json in every release lists all crates with their versions and licenses (via anchore/sbom-action). Usable with syft / grype for vulnerability audits.

Phase B — open loose ends

  • macOS Notarization: xcrun notarytool submit with a Developer ID certificate + Apple ID + app-specific password. The secrets APPLE_ID, APPLE_TEAM_ID, APPLE_NOTARY_PASSWORD need to be set in the GitHub repo secrets.
  • Windows code-signing: signtool sign with an EV certificate. Azure Code Sign is an alternative for CI-friendly token-based signing.
  • Logo / icon design: Currently only a 1×1 placeholder in the AppImage; macOS and Windows have no icon. Once the design is finalized (see ROADMAP.md), it needs to be placed under packaging/{appimage,macos,windows}/.

Security

Operator-focused overview of what dvb-WarpPool protects, how it does so, and which responsibilities remain with the operator.

Threat Model

The pool sits between four actor classes with different levels of trust:

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 burst3
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.3 only, no TLS 1.0/1.1, no RSA key exchange)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 (cosign-keyless OIDC), reproducible builds6, 8b, 8g
Memory safetyRust with no unsafe except in NOISE/ZMQ bindings that are wrapped by upstream crates
Stratum replayDedup per (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:true3.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
  -> audit: 2fa.enable

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 m=64MiB, t=3 — 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_static_priv_key_hexsecrets.tomlchmod 0600With the Sv2-NOISE static key someone can impersonate the pool toward V2 miners. Generated with 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 VAPIDPhase B (not yet active)would be secrets.tomlWith 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/*
token.create / token.revoke/api/admin/tokens/*
profile.switch/api/admin/profile
miner.add / miner.remove/api/admin/miners/*
backup.export / backup.restore/api/admin/backup / /api/admin/restore
update.check / update.applied / update.failed/api/admin/update*
notifier.test/api/admin/notifier/test

Fields: at (timestamp), actor (user, or token:<name>, or ? for failed login), action, target (optional, action-specific), peer_ip (with X-Forwarded-For resolution when trust_proxy_headers=true), ok (boolean), details (optional, e.g. error description).

Retrieve via GET /api/admin/audit?limit=100&actor=admin or CLI:

dvb-warppool-cli audit --limit 50 --actor admin

The table has indexes on at, actor, action for fast filtering. Eviction: evict_audit_older_than can be called per cron (no auto-eviction in the daemon — the operator decides the retention policy).

TLS

dvb-WarpPool has two separate TLS surfaces:

Stratum-V1 TLS (port :3334 typical)

Optional. If stratum.tls_cert_path + stratum.tls_key_path are set, a second listener runs alongside the plain port. Rustls 0.23, default suite — no RSA key exchange, no PSK-only auth.

The operator provides cert+key themselves — typically via Let's Encrypt (certbot with DNS challenge when the pool is not publicly reachable) or mkcert for LAN-only setups.

Stratum-V2 NOISE (port :3334 or other)

NOISE-NX handshake with the snow crate, pure Rust. Static Curve25519 key in secrets.sv2_static_priv_key_hex. The public key the V2 miner needs as its server-pubkey is available three ways:

  • Pool UI: dashboard → "⛏ Miner verbinden" button → the connect modal shows the V2 endpoint and the server pubkey with a copy button.
  • API: GET /api/overview → field sv2_pubkey_hex (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.2.3 linux-x86_64
# fetches the release asset + rebuilds locally with the same flags + compares sha256

Phase 8g: POST /api/admin/update accepts cosign_verify: bool. If true, cosign verify-blob runs as an external subprocess before atomic_swap. The operator must set the WARPPOOL_COSIGN_BIN env var — otherwise hard-fail (500), not a silent skip.

Cosign verify uses sigstore-keyless via the workflow OIDC token from the release workflow. The trust anchors are therefore GitHub Actions OIDC + Fulcio. For anyone who does not trust that: the GPG workflow has been available since Phase 6, but is not enabled by default.

Stratum Hardening

Rate Limiting (Phase 3)

Token bucket per peer IP. Defaults:

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.

Connection Cap

Profile-dependent (Small: 64, Medium: 256, Large: 1024, Enterprise: 4096). Over-limit connects are dropped at accept time; semaphore-based, no per-connection allocation beforehand.

Conservative Mode (safe_mode)

When stratum.safe_mode = true (default): the share validator rejects unusual shares (unusual version bits, suspicious ntime drift). The operator can set this to false when testing experimental hardware.

Audit-Friendly Defaults

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 an encryptable binary blob (PR pending — currently plain SQLite bytes). Restore via POST /api/admin/restore.

Audit: backup.export + backup.restore. On backup.restore a fresh migration runs after the import so that schema drifts between the backup version and the current version are resolved.

What is in the backup: all DB tables incl. admin_2fa.secret_base32, api_tokens.token_hash, audit_log. What is NOT included: secrets.toml, config.toml, ~/.bitcoin/.cookie. Anyone backing up both must protect them separately.

Sandbox + Privileges

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, GitHub issue only after a fix. We are a small crew without a formal bug bounty — good-faith reports are acknowledged with credit.

SECURITY.md in the repo root holds the official policy

  • PGP key on request.

What is intentionally NOT implemented

Would be 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 disclosure/api/miners, /metrics, /api/energy are readable without auth and leak internal miner IPs/hostnames/hashrates. Deliberate MVP read-only design. Before untrusted-multi-tenant or public exposure, gate behind auth (or bind /metrics to localhost)
Setup-wizard unauth file-read / SSRF (localhost)/api/test-rpc / /api/bitcoin-health / /api/import-gopool/load read request-supplied paths and POST to arbitrary URLs without auth/CSRF/origin check. Default bind 127.0.0.1, short-lived. Before a non-loopback --bind, require a CSRF/origin check or a one-time token
Web Push DNS-rebinding 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 + SLSA provenance say "the build came from this workflow". Reproducible builds additionally say "the build is the source".
  • Audit-friendly — pool operators can periodically verify that the pulled image matches the tagged source.

How we achieve it

Cargo profile (in Cargo.toml)

[profile.release]
lto = "fat"           # single-threaded LTO, no parallelism drift
codegen-units = 1     # a single LLVM pass
strip = "symbols"     # removes debug-info drift
panic = "abort"
incremental = false   # explicit

lto = "thin" was deliberately switched to "fat" — thin-LTO is faster (parallel) but can produce byte drift between host configurations.

Toolchain pin

rust-toolchain.toml pins the rustc + cargo version. CI and local verification use exactly this version.

Path remapping

Build-time paths ($CARGO_HOME, $GITHUB_WORKSPACE, $HOME) would otherwise end up embedded as debug info in the binary. We remap them to generic strings:

RUSTFLAGS="\
  --remap-path-prefix=$WORKSPACE=/repo \
  --remap-path-prefix=$HOME/.cargo/registry=/cargo/registry \
  --remap-path-prefix=$HOME/.cargo/git=/cargo/git"

SOURCE_DATE_EPOCH

The release.yml workflow sets SOURCE_DATE_EPOCH from the commit timestamp. deb/rpm archives use it for mtime fields instead of now(). (The Rust binary itself currently does not embed timestamps, but wrappers and installers do.)

Cargo.lock

We commit Cargo.lock and the build uses --locked. This also pins every transitive dependency version.

Verifying — CI side

Every push to main triggers repro.yml:

build-a ─┐
         ├─► verify-determinism (sha256 comparison)
build-b ─┘

Two separate runners build the same dvb-warppool-daemon binary. On drift the workflow fails and prints both sha256s.

Verifying — end-user side

scripts/verify-reproducible.sh is a bash script that:

  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 GitHub release asset
  5. compares both sha256s and exits 0 (match) or 1 (drift)
# Verify the latest release
./scripts/verify-reproducible.sh

# A specific version
./scripts/verify-reproducible.sh v0.1.0

# Keep build artifacts for debugging
./scripts/verify-reproducible.sh --keep-target

Limitations

  • Linux-x86_64 only — macOS .dmg and Windows .msi are not byte-deterministic (code signing, installer metadata). Cosign signatures cover those.
  • Glibc drift — if a pool operator builds on an old distro (Debian Buster) while the CI runner uses Ubuntu Latest, glibc drift can occur. Workaround: build in the same Docker image the CI uses, or use a static MUSL build (phase 8c).
  • LLVM version drift — rust-toolchain.toml pins rustc, but the LLVM version shipped with it varies minor across rustup channels. We accept this as a known limitation.

When the build drifts

  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 runs in two phases: 8c Foundation (this doc) provides update-check + verified download as CLI subcommands; 8d Wiring (roadmap) brings atomic swap + health check + rollback into the daemon itself.

Current MVP (Phase 8c)

Two CLI commands, manually triggered by the operator:

dvb-warppool-cli check-update

Fetches the latest release from GitHub and compares it against the installed version (CARGO_PKG_VERSION from the CLI binary).

$ dvb-warppool-cli check-update
Installed:    0.1.0
Latest:       v0.2.0 (Release 0.2.0)
Update:       0.1.0 → 0.2.0  (available)

Release-Assets:
  · dvb-warppool-daemon-linux-x86_64 (12345678 bytes)
  · dvb-warppool-daemon-linux-aarch64 (11456789 bytes)
  · SHA256SUMS (256 bytes)
  · SHA256SUMS.sig (96 bytes)

Optional --json for scripting. --repo owner/name for forks or tests.

dvb-warppool-cli download-update

Downloads a release asset with sha256 verification. Performs no atomic swap and no restart — that's the operator's job, so the workflow stays auditable.

# Latest binary for the local host (auto-detected linux-x86_64/-aarch64/macos)
$ dvb-warppool-cli download-update \
    --to /tmp/dvb-warppool-daemon.new \
    --sha256 abcd1234...  # aus SHA256SUMS

Download: dvb-warppool-daemon-linux-x86_64 (12345678 bytes) → /tmp/dvb-warppool-daemon.new
✓ 12345678 bytes written, sha256=abcd1234...
→ Manual atomic swap (Phase 8d wires this into the daemon):
    chmod +x /tmp/dvb-warppool-daemon.new
    sudo mv /tmp/dvb-warppool-daemon.new /usr/local/bin/dvb-warppool-daemon
    sudo systemctl restart dvb-warppool

On sha256 mismatch the half-written file is deleted — no "tainted download" left lying around.

#!/bin/bash
set -euo pipefail
TAG=$(dvb-warppool-cli check-update --json | jq -r .latest)
[ "$TAG" = "null" ] && exit 0  # no update

# fetch SHA256SUMS from the release and verify with cosign
curl -sSfLO https://github.com/dvb-projekt/dvb-WarpPool/releases/download/$TAG/SHA256SUMS
curl -sSfLO https://github.com/dvb-projekt/dvb-WarpPool/releases/download/$TAG/SHA256SUMS.sig
cosign verify-blob \
  --certificate-identity-regexp "https://github.com/dvb-projekt/dvb-WarpPool/.github/workflows/release.yml@.*" \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --signature SHA256SUMS.sig SHA256SUMS

# extract the expected sha256 for our asset
ASSET="dvb-warppool-daemon-linux-x86_64"
EXPECTED=$(awk -v a="$ASSET" '$2 == a {print $1}' SHA256SUMS)

# download + verify
dvb-warppool-cli download-update --to /tmp/$ASSET --sha256 "$EXPECTED"

# manual swap (Phase 8d automates this)
sudo install -m 755 /tmp/$ASSET /usr/local/bin/dvb-warppool-daemon
sudo systemctl restart dvb-warppool

Crate internals (warppool-autoupdate)

#![allow(unused)]
fn main() {
use warppool_autoupdate::UpdateChecker;

let checker = UpdateChecker::new("dvb-projekt/dvb-WarpPool");
let release = checker.fetch_latest().await?;
if let Some(newer) = checker.is_newer_than(&release, env!("CARGO_PKG_VERSION"))? {
    // update available
}
}

Modules:

  • version — semver-subset parser (major.minor.patch + optional -pre), compared via Ord. Pre-release < stable (semver convention).
  • release — GitHub releases API 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": "0.1.0",
  "latest": "v0.2.0",
  "name": "Release 0.2.0",
  "prerelease": false,
  "newer": "0.2.0",
  "assets": [
    {"name": "dvb-warppool-daemon-linux-x86_64", "size": 12345678, "url": "..."},
    {"name": "SHA256SUMS", "size": 256, "url": "..."}
  ]
}

Audit: update.check with the resolved latest tag as target.

POST /api/admin/update

Downloads the chosen asset, verifies sha256, atomic-renames into target_path. Does not send a restart — the operator does that via systemctl restart from the restart_hint field.

curl -X POST -H "Authorization: Bearer $TOKEN" \
     -H "Content-Type: application/json" \
     -d '{
       "asset": "linux-x86_64",
       "sha256": "abcd1234...",
       "target_path": "/usr/local/bin/dvb-warppool-daemon",
       "backup_path": "/var/lib/dvb-warppool/backup/daemon.v0.1.0"
     }' \
     http://localhost:18334/api/admin/update

{
  "tag": "v0.2.0",
  "asset": "dvb-warppool-daemon-linux-x86_64",
  "bytes": 12345678,
  "sha256": "abcd1234...",
  "target_path": "/usr/local/bin/dvb-warppool-daemon",
  "backup_path": "/var/lib/dvb-warppool/backup/daemon.v0.1.0",
  "restart_hint": "systemctl restart dvb-warppool"
}

Audit: update.applied (ok=true) or update.failed (ok=false with details). The operator can review the update history via /api/admin/audit.

Activation

In dvb-warppool.service (systemd):

[Service]
Environment="WARPPOOL_AUTOUPDATE_REPO=dvb-projekt/dvb-WarpPool"

Or directly:

WARPPOOL_AUTOUPDATE_REPO=dvb-projekt/dvb-WarpPool dvb-warppool-daemon

Phase 8e — Periodic auto-check + SSE event

When auto-update is enabled (WARPPOOL_AUTOUPDATE_REPO=…), the daemon spawns a background task that runs fetch_latest every N hours. On a newer release an update_available SSE event is pushed to the event bus — the UI subscribes to it and can show an "Update available" banner without the operator having to click update-check manually.

Activation

# Standard: 24h interval
WARPPOOL_AUTOUPDATE_REPO=dvb-projekt/dvb-WarpPool dvb-warppool-daemon

# Custom interval
WARPPOOL_AUTOUPDATE_REPO=dvb-projekt/dvb-WarpPool \
WARPPOOL_AUTOUPDATE_INTERVAL_HOURS=6 \
  dvb-warppool-daemon

# Periodic disabled, on-demand only via /api/admin/update-check
WARPPOOL_AUTOUPDATE_REPO=dvb-projekt/dvb-WarpPool \
WARPPOOL_AUTOUPDATE_INTERVAL_HOURS=0 \
  dvb-warppool-daemon

Initial delay is 60s after daemon start so that not every pool instance hits GitHub at the same time.

SSE event schema

Subscribe via Browser EventSource at /api/events. The event looks like:

event: update_available
data: {"type":"update_available","at":"2026-05-26T12:00:00Z","current":"0.1.0","latest":"v0.2.0","name":"Release 0.2.0","prerelease":false}

JavaScript example for a UI banner:

const es = new EventSource("/api/events");
es.addEventListener("update_available", (ev) => {
  const data = JSON.parse(ev.data);
  showBanner(`Update available: ${data.current} → ${data.latest}`);
});

On errors (rate-limit, network-down)

The task logs warn! and retries on the next interval. The GitHub API allows 60 unauthenticated requests/h per IP — with the default 24h interval we land at <2 requests/day, well below the limit.

Phase 8f — Systemd OnFailure rollback

So that a bad auto-update doesn't leave the daemon in a permanently-down state, the shipped dvb-warppool.service has an OnFailure= hook that on repeated crashes (StartLimitBurst=4 times within StartLimitInterval=300s) restores the most recent backup and restarts.

Layout

/usr/lib/systemd/system/dvb-warppool.service           ← main service
/usr/lib/systemd/system/dvb-warppool-rollback.service  ← oneshot helper
/usr/lib/dvb-warppool/rollback.sh                      ← restore script

Flow

  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": "linux-x86_64",
  "sha256": "...",
  "target_path": "/usr/bin/dvb-warppool-daemon",
  "backup_path": "/var/lib/dvb-warppool/backup/daemon.v0.1.0"
}

The atomic_swap function moves the current binary to backup_path before installing the new one — so on a crash rollback.sh has something to restore.

Testing the helper (local, without systemd)

tmp=$(mktemp -d)
bin=$(mktemp -d)
echo "OLD" > "$tmp/daemon.v0.1.0"
echo "CRASHY" > "$bin/dvb-warppool-daemon"
# mock systemctl so no real restart runs
printf '#!/bin/bash\necho mocked\n' > "$tmp/systemctl"
chmod +x "$tmp/systemctl"
PATH="$tmp:$PATH" \
  WARPPOOL_BACKUP_DIR="$tmp" \
  WARPPOOL_TARGET_BIN="$bin/dvb-warppool-daemon" \
  packaging/systemd/rollback.sh
cat "$bin/dvb-warppool-daemon"  # → OLD

Phase 8g — Cosign-verify integrated

POST /api/admin/update can optionally run a cosign verify-blob subprocess invocation before the sha256 check. This matches sigstore's keyless OIDC flow that our release.yml has been using since Phase 6.

Why subprocess instead of pure-rust sigstore

The pure-rust sigstore crate would pull in 30+ transitive deps (ASN.1, X.509, certificate-chain parser, rekor client). A cosign CLI subprocess is:

  • Transparent — the operator sees the exact command in the audit log
  • Current — cosign updates come from sigstore, not from us
  • Minimal — no extra Rust deps

Trade-off: the operator must have cosign installed.

Activation

Two prerequisites:

  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": "linux-x86_64",
       "sha256": "abcd1234...",
       "target_path": "/usr/bin/dvb-warppool-daemon",
       "backup_path": "/var/lib/dvb-warppool/backup/daemon.v0.1.0",
       "cosign_verify": true,
       "cosign_args": [
         "--signature=https://github.com/dvb-projekt/dvb-WarpPool/releases/download/v0.2.0/SHA256SUMS.sig",
         "--certificate=https://github.com/dvb-projekt/dvb-WarpPool/releases/download/v0.2.0/SHA256SUMS.pem",
         "--certificate-identity-regexp=https://github.com/dvb-projekt/dvb-WarpPool/.github/workflows/release.yml@.*",
         "--certificate-oidc-issuer=https://token.actions.githubusercontent.com"
       ]
     }' \
     http://localhost:18334/api/admin/update

Internally (after the download) the server runs cosign verify-blob on the downloaded SHA256SUMS file. The release only signs that file — per-binary integrity is then established by matching the binary's sha256 against its line in SHA256SUMS:

$WARPPOOL_COSIGN_BIN verify-blob \
    --signature=... --certificate=... \
    --certificate-identity-regexp='https://github.com/dvb-projekt/dvb-WarpPool/.github/workflows/release.yml@.*' \
    --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
    /tmp/SHA256SUMS

If cosign exits ≠ 0 → the downloaded file is deleted, response 403, audit update.failed with cosign verify-blob failed: exit N.

Security model

LayerWhat is checked
sha256File integrity against the hash supplied by the operator
cosign verify-blobSignature authenticity against sigstore OIDC issuer + identity regex
cosign_argsFully operator-controlled — the server only appends the downloaded file as the last 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

Limitations today

  • macOS .dmg and Windows .msi are not raw binaries — the operator can only update those via the respective native installers. Auto-update is Linux-first.
  • No diff updates (full binary only). At 15 MB / release that's harmless.
  • Cosign-verify is operator-side (see flow above), not built into the CLI — deliberately, so the operator sees the trust anchor.

See also

Architektur

dvb-WarpPool ist ein Rust-Workspace mit 16 Library-Crates + 6 Binaries. Eine klare Schichtung — von puren Schema-Crates ohne async unten bis zu den async-orchestrierten Binaries oben — macht das System gut testbar und ermöglicht Sub-Crates separat zu betreiben (z.B. der Translator als sidecar ohne Pool-Daemon).

Crate-Übersicht

Schema + Foundation (ohne Async)

CrateWas
warppool-profilesAdmin-Profile (Klein/Mittel/Gross/Enterprise) — capacity + defaults
warppool-configTOML-Schema (PoolConfig + Sub-Configs + Secrets)
warppool-hwdetectHardware-Detection via sysinfo → Profile-Empfehlung

Core Mining-Layer

CrateWas
warppool-bitcoin-rpcJSON-RPC + ZMQ-Subscribe für Bitcoin Core
warppool-job-builderCoinbase-Tx + Merkle-Tree + Stratum-Job-Konstruktion
warppool-share-validatorShare-PoW-Check + Block-Found-Detection
warppool-stratum-v1TCP-Listener, Session-State-Machine, VarDiff
warppool-stratum-v2NOISE-NX-Handshake, binary-framing, Mining-Subprotocol
warppool-translatorV1↔V2-Sidecar (Pool kann nur Sv2 anbieten, V1-Miner via Translator)

Storage + API

CrateWas
warppool-storageSQLite via sqlx, alle Tabellen + Migrations
warppool-apiAxum-HTTP-API (REST + SSE-Stream + statisches UI-serving)

Operationale Subsysteme

CrateWas
warppool-healthBitcoin-Core-Multi-RPC-Health + bitcoin.conf-Parser + Snippet-Generator
warppool-autoupdateVersion-Parser + GitHub-Release-Client + atomic_swap + Cosign-Hook
warppool-notifierPush-Sinks (ntfy/Telegram/Discord/Slack/Email) + Counter-Metrics
warppool-telemetryVendor-API-Probes (AxeOS, NerdNOS, ...) + mDNS-Discovery + PoolMetrics

Tools

CrateWas
warppool-simulatorSim-Miner (Vendor-Personas) + Sim-Node + Scenarios

Binaries

BinaryZweck
dvb-warppool-daemonDer Pool — orchestriert alle Subsysteme
dvb-warppool-cliOperator-Tools (hash-password, token-create, set-profile, check-update, ...)
dvb-warppool-setupFirst-Run-Wizard (Axum, Modern-UI, embedded HTML)
dvb-warppool-translatorV1→V2 Sidecar (clap-CLI, kann als systemd-service laufen)
dvb-warppool-simSimulations-Runner (scenario list / run)

Datenfluss (Happy Path)

                            +---------------------+
                            | Bitcoin Core        |
                            | (mainnet / regtest) |
                            +---------------------+
                              | RPC          | ZMQ pub
                              v              v
                            +---------------------+
                            | bitcoin-rpc         |
                            |  - getblocktemplate  |
                            |  - submitblock       |
                            |  - hashblock watch   |
                            +---------------------+
                                   |
                                   v
                            +---------------------+
                            | job-builder         |
                            |  - coinbase splits  |
                            |  - merkle tree      |
                            |  - StratumJob       |
                            +---------------------+
                                   |
                       +-----------+-----------+
                       v                       v
              +------------------+   +------------------+
              | stratum-v1       |   | stratum-v2       |
              |  TCP / TCP+TLS   |   |  TCP+NOISE-NX    |
              +------------------+   +------------------+
                       |                       |
                       v                       v
               V1 Miner (Bitaxe,       V2 Miner / Translator
               NerdNOS, AntminerS23)
                       |                       |
                       v                       v
                      submit (extranonce, nonce, ntime)
                                   |
                                   v
                            +---------------------+
                            | share-validator     |
                            |  - PoW check        |
                            |  - block detection  |
                            +---------------------+
                                   |
                       +-----------+-----------+
                       v                       v
              +------------------+   +------------------+
              | storage          |   | block-submit-loop|
              |  - record_share  |   |  - assemble block|
              |  - vardiff_state |   |  - submitblock   |
              +------------------+   +------------------+
                                              |
                                              v on success
                            +---------------------+
                            | notifier            |
                            |  fire BlockFound    |
                            +---------------------+
                              |    |    |    |    |
                              v    v    v    v    v
                            ntfy tg disc slack email

Daemon-Task-Topologie

dvb-warppool-daemon ist ein Tokio-Multi-Reactor mit ~10 langlebigen Tasks plus pro-Verbindung-Tasks. Alle Tasks teilen sich einen CancellationToken für graceful shutdown.

                  +------- main() async -------+
                  |                            |
                  +- spawn ---------+----------+
                          |         |
                  +-------+-------+ +------------------+
                  | Stratum-V1     | Stratum-V2        |
                  | accept-loop    | accept-loop       |
                  | (+ TLS)        | (+ NOISE-NX)      |
                  +----------------+ +-----------------+
                          |               |
                  pro Connection: Session-Task
                          |               |
                          v               v
                  +---------------------------------+
                  | block_found_tx broadcast        |
                  +---------------------------------+
                          |
                  +-------+--------+
                  | block_submit_  |
                  | loop           |
                  +----------------+
                          |
                  Bitcoin Core RPC submitblock
                          |
                  +-------+--------+
                  | notifier.notify(BlockFound)     |

                  +- spawn ---------+----------+
                  | job_refresh_loop                |
                  |  - poll getblocktemplate (60s)  |
                  |  - ZMQ hashblock fast-path      |
                  |  - drain & build job            |
                  |  - push to stratum handles      |
                  +---------------------------------+

                  +- spawn ---------+----------+
                  | aggregate_loop (60s tick)       |
                  |  - storage.aggregate_5min       |
                  |  - SharesAccepted SSE event     |
                  +---------------------------------+

                  +- spawn ---------+----------+
                  | health_check_loop (60s tick)    |
                  |  - check_bitcoin_health         |
                  |  - SSE HealthSnapshot           |
                  |  - RpcDown/Recovered notify     |
                  +---------------------------------+

                  +- spawn ---------+----------+
                  | periodic_update_check (24h)     |
                  |  - GitHub Releases API          |
                  |  - SSE UpdateAvailable          |
                  +---------------------------------+

                  +- spawn ---------+----------+
                  | miner_poll_loop (vendor probes) |
                  +---------------------------------+

                  +- spawn ---------+----------+
                  | HTTP API (Axum on :18334)       |
                  |  - REST endpoints               |
                  |  - SSE /api/events              |
                  |  - static UI from --ui-dir      |
                  +---------------------------------+

Shared State

Tokio-Tasks teilen Arc<...>-Handles statt globaler Statics:

HandleTypGenutzt von
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::Senderalle Subsysteme — publish; API → subscribers (SSE)
storage: Arc<Storage>sqlx-Poolshare-recording, audit-log, vardiff-state, settings
snapshot: Arc<RwLock<NetworkSnapshot>>RwLock-Snapshotjob_refresh_loop schreibt; API liest
profile_kind: Arc<RwLock<ProfileKind>>hot-switchableAPI admin-route + display
cancel: CancellationTokenpropagationalle tasks (graceful shutdown)

Storage-Schema

Tabellen aus den Phasen 1-15. Migrations in crates/storage/migrations/.

TabellePhaseWas
workers1Worker-Liste (user, last_seen_at, shares_accepted/rejected, blocks_found)
shares_raw1Letzte 1h roh-shares — basis für hashrate-Berechnung
shares_5min1Aggregierte 5min-Buckets — Hashrate-Chart-Daten
blocks_found1Block-History (height, hash, coinbase_value_sats, found_at)
pool_settings2.5Generisches KV-Store (active_profile_kind, etc.)
vardiff_state2.5Pro-Worker VarDiff-Snapshots (current_diff, ema, last_share_unix)
audit_log3Admin-Actions (actor, action, target, peer_ip, ok, details)
api_tokens3.2Persistente 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, Phase B nicht aktiv)

Sv2-Stack im Detail

Stratum V2 ist ein binary-framed protocol mit NOISE-NX-handshake davor. Im Pool-Kontext relevant sind zwei Subprotocols:

  1. Mining-Subprotocol (implementiert) — Channel-basiert, Extranonce-aware, Version-Rolling per BIP-320, Set-Target pro Channel.
  2. Template-Distribution-Protocol (TDP, Foundation in Phase 7.6a, Wiring in 7.6b deferred) — Ersetzt getblocktemplate durch push-driven Template- Updates direkt vom Bitcoin-Node.
V1-Miner ----TCP plain---->  stratum-v1 server  ----------+
                                                          |
V1-Miner ----TCP+TLS------>  stratum-v1 TLS-server -------+
                                                          |
                                                          v
                                              +------------------+
                                              |  job-builder     |
                                              |  share-validator |
                                              +------------------+
                                                          ^
                                                          |
V2-Miner ----TCP+NOISE----> stratum-v2 server (port 3334) +
                                                          |
V1-Miner ----TCP--+                                       |
                  |                                        |
                  v                                        |
     dvb-warppool-translator (sidecar) --TCP+NOISE--------+
       - SetupConnection
       - OpenExtendedMiningChannel mit user_identity
       - SubmitSharesExtended mit miner-extranonce
       - Receives NewExtendedMiningJob + SetNewPrevHash
       - Maps to V1 mining.notify + slushpool prev_hash + BIP-320 version-rolling

Konkretes Wire-Format ist in crates/stratum-v2/src/messages.rs mit Roundtrip-Tests. NOISE-Handshake in crates/stratum-v2/src/noise.rs mit snow-crate (pure-Rust, kein OpenSSL).

Connection-Lifecycle-Hooks

Beide Stratum-Server haben einen optionalen ConnectionSink-Trait. Bei authentifizierten Workers (V1: mining.authorize, V2: OpenChannel mit user_identity) feuert on_authorized; bei Verbindungsende on_disconnect.

Daemon implementiert eine NotifierConnectionSink-Struct die beide Traits implementiert. Pro-Worker-Debounce (default 30s) verhindert dass flappende Miner pro Reconnect eine Notification feuern.

   v1.Session::run
         |
   handle_authorize → connection_sink.on_authorized
         |
   ... shares ...
         |
   loop exit → connection_sink.on_disconnect

   v2.handle_connection
         |
   process_frame → channels().iter() → new user_identity?
         |                                    → on_authorized
   ... shares ...
         |
   loop exit → for each notified_user → on_disconnect

Event-Bus + SSE

PoolEventBus ist ein tokio-broadcast-channel mit PoolEvent-Variants:

EventSource
BlockFoundblock_submit_loop
NewJobjob_refresh_loop
SharesAcceptedaggregate_loop
HealthSnapshothealth_check_loop (Phase 13b)
UpdateAvailableperiodic_update_check (Phase 8e)

API /api/events öffnet pro Client einen SSE-Stream mit dem aktuellen Subscription-Set. UI nutzt das für HealthBanner + UpdateBanner + Toast- Events.

Konfigurations-Layer

/etc/dvb-warppool/
├── config.toml         # nicht-sensitive Settings
├── secrets.toml        # chmod 600 — admin-hash, jwt-secret, rpc-pass, sv2-key
└── pool.db             # SQLite (Path konfigurierbar)

Env-vars für opt-in subsysteme (siehe Configuration Reference): WARPPOOL_HEALTH_CHECK_INTERVAL_SECONDS, WARPPOOL_AUTOUPDATE_REPO, WARPPOOL_DISCONNECT_DEBOUNCE_SECS, sink-spezifische tokens, etc.

Lifecycle (Daemon-Boot)

  1. Parse CLI (clap) + ENV
  2. Load config.toml + (optional) secrets.toml
  3. Validate config (mining.payout_address pflicht, ratelimit constraints, ...)
  4. Init tracing-subscriber (JSON wenn logging.json)
  5. Open SQLite + run migrations
  6. Resolve admin profile (persisted in pool_settings schlägt config-default)
  7. Construct BitcoinRpc mit with_metrics(pool_metrics)
  8. Probe RPC (warning bei Fail, Daemon startet trotzdem)
  9. Construct Notifier aus config
  10. Construct PoolMetrics (Arc-shared)
  11. Build initial job (oder skip wenn RPC fail)
  12. Spawn Stratum-V1-Listener (+ TLS-listener wenn cert/key configured)
  13. Spawn Stratum-V2-Listener wenn sv2_listen configured + sv2_static_priv_key_hex da
  14. Spawn job_refresh_loop (poll + optional ZMQ)
  15. Spawn block_submit_loop
  16. Spawn aggregate_loop (60s)
  17. Spawn health_check_loop wenn WARPPOOL_HEALTH_CHECK_INTERVAL_SECONDS > 0
  18. Spawn periodic_update_check wenn WARPPOOL_AUTOUPDATE_REPO + interval > 0
  19. Spawn HTTP API (Axum auf status_listen)
  20. Install signal-handlers (SIGTERM → cancel.cancel() → alle tasks shutdown)
  21. tokio::main event-loop läuft bis cancel

Crate-Abhängigkeiten (DAG)

Vereinfachtes Crate-Graph (nicht alle Edges; keine zyklischen Deps):

profiles --+
hwdetect --+--> config
              |
              +--> storage
              +--> bitcoin-rpc --+
              +--> job-builder   |
              +--> share-validator
              +--> stratum-v1 -----+
              +--> stratum-v2 -----+
              +--> translator -----+
              +--> telemetry -----+
              +--> notifier ------+
              +--> autoupdate ----+
              +--> health --------+
              +--> api -----------+
              +--> simulator -----+
                                 |
                                 v
                  dvb-warppool-daemon
                  dvb-warppool-cli
                  dvb-warppool-setup
                  dvb-warppool-translator
                  dvb-warppool-sim

Crates und ihre einseitigen Deps:

  • telemetry ← keine (foundation für metrics)
  • bitcoin-rpctelemetry (für RPC-Latency-Histogram, Phase 16.3)
  • apinotifier + autoupdate + telemetry + storage + ...
  • daemon → alle library-Crates plus runtime-deps (tokio, sqlx, ...)

Testing-Strategie

Drei Ebenen, plus eine zusätzliche operator-driven Ebene:

EbeneWasToolTest-Count
UnitPure-logic pro Cratecargo test -p warppool-<crate>~330
IntegrationMehrere Crates in-process, axum-Mock-Servertests/ pro Crate~115
SimGegen den echten Daemon-Prozess, Vendor-Personasdvb-warppool-sim scenario <name>5 scenarios
Regtest E2EGegen echtes bitcoind im regtestscripts/regtest-up.sh + --ignored3 tests (opt-in)

Stand: 447 Tests + 3 ignored, alle grün. UI-side: pnpm svelte-check → 294 files, 0 errors.

Siehe TESTING.md für Details + Simulator-Workflow.

dvb-WarpPool — UI Design Konzept

Recherche-Stand: Mai 2026. Direkter Site-Fetch war nicht verfügbar — alle Findings via Web-Search-Synthese + Review-Quellen. Konzepte sind keine Kopien, sondern Inspirations-Synthese.


1. Findings

atlaspool.io

  • Address-first Flow: Wallet-Adresse oben als Such-Eingabe, kein Login. Sichtbarkeits-Toggle für Screenshots — smart.
  • Stats-Stack: Real-time Hashrate, Mining-Streak, Pool-Contribution, Active Workers, Best Share, First-Seen, Est. Block Time, Achievements (Gamification, 2026 angekündigt).
  • Netzwerk-Kontext: Block-Height, Network-Hashrate, Difficulty, BTC-Preis als Glance-Row — gut, weil Mining ohne Netzwerk-Kontext sinnlos ist.
  • Hashrate-Graph mit Multi-Timeframe: 1D/1W/1M/All — Standard, aber sauber.
  • Refresh alle 2 min — kein Echtzeit-Push, eher Polling. Verbesserungspotenzial.

ocean.xyz

  • Permissionless, address-keyed: keine Accounts. Stats kleben an Payout-Adresse.
  • "Shares in Reward Window": TIDES-spezifische Metrik prominent — sie machen ihre Payout-Logik sichtbar.
  • Quick-Read Metriken (Daily Earnings, Lifetime, Progress-to-Payout, Unpaid) als Cards. Wenig glamourös, hoch informativ.
  • Updated Feb 2026 — UI eher funktional-konservativ, kein Show-Off.

DeepSea-Dashboard (Community-Frontend für Ocean)

  • Retro CRT-Terminal-Aesthetic: Scanlines, Phosphor-Glow — bewusst nostalgisch.
  • Drei Themes: DeepSea (blau), Bitcoin (orange), Matrix (grün). Theme als Identity.
  • Chart.js mit Block-Annotations, Worker-Cards mit ASIC-Detection, Pool-Donut, Sound-Effekte bei New Block.
  • Atmosphäre statt Information-Density — emotional engagement durch Audio + Visual.
  • Zoom-bare Hashrate-Charts, regex-Filterung der Worker, API-First.
  • 2026-Split: Techno-Futurist (dark + neon + shaders + bento) vs. Editorial (cream + serif + whitespace). Crypto sitzt fast immer im ersten Lager.
  • Sidebar + Cards (Linear/Vercel/Stripe-Pattern) skaliert ohne Restruktur.
  • Glassmorphism: nur noch sparsam, Brutalism als Gegenbewegung gewinnt.

2. Drei Design-Konzepte

Konzept A — "Warp Drive" (Cinematic Cosmos)

  • Stil: Glassmorphism + Deep-Space. WebGL-Sterne im Background (subtil, ≤40 Partikel), shader-Gradients à la Stripe/Vercel, Glass-Cards mit backdrop-filter.
  • Palette: #05060B (void), #0F1226 (deep), #7B5CFF (warp violet), #00E0FF (plasma cyan), #F7931A (BTC orange als Akzent, sparsam).
  • Typo: Header Space Grotesk (700, tight tracking). Body Inter (15px). Numerik JetBrains Mono (tabular-nums).
  • Layout: Bento-Grid 12-spaltig, asymmetrisch. Hero = großer Live-Hashrate-Block (4×3), kleine Cards für Workers/Block-History.
  • Daten-Viz: Animated counters (Web Animations API), sparklines mit Gradient-Fill, Hashrate-Chart mit scroll-driven Annotations für Found-Blocks.
  • Moderne Techniken:
    • View Transitions API für Adress-Lookup → Dashboard
    • CSS @scroll-timeline für Block-History-Reveal
    • color-mix() für theme-reaktive Glow-States
    • Popover API für Worker-Detail-Drawer
  • Mobile: Bento kollabiert via Container Queries zu Single-Column-Stack. WebGL-Shader auf prefers-reduced-motion → statisches Gradient.
  • Gut: emotional, "future of mining", differenziert klar vom Vorgänger. Risiko: WebGL-Performance auf alten Phones, Glass kann Lesbarkeit zerstören wenn Background zu busy ist.

Konzept B — "Stratum" (Brutalist-Terminal)

  • Stil: Monospace-first, harte Linien, sichtbares Grid, ASCII-Akzente. Inspiration: Linear-Logs + Bitaxe AxeOS + Recurse-Center-Vibe.
  • Palette: #0A0A0A (true black), #1A1A1A (panel), #E8E8E8 (text), #39FF14 (signal green für "alive"), #FF4D00 (alert orange). Pure, vier Töne.
  • Typo: Komplett Berkeley Mono oder JetBrains Mono. Header größer (28-40px), Body 14px. Keine zweite Familie.
  • Layout: Sidebar (240px) + Long-Scroll-Mainpanel. Sections wie Terminal-Output mit ───── Trennern. Command-Palette (⌘K) als zentrale Navigation.
  • Daten-Viz: ASCII-style Sparklines (▁▂▃▅▇▆▄▂), Heatmaps als CSS-Grid mit color-mix(), Block-History als Log-Stream mit Timestamps.
  • Moderne Techniken:
    • text-wrap: balance für Headlines
    • Container Queries statt Breakpoints — jede Card adaptiert lokal
    • Anchor Positioning für Tooltips auf Chart-Datenpunkten
    • @property für animierbare CSS-Variablen (Hashrate-Pulse)
  • Mobile: Sidebar wird Top-Bar mit Hamburger, Long-Scroll bleibt — Brutalism skaliert ehrlich.
  • Gut: technisch glaubwürdig (passt zu Rust-Backend), extrem performant, alterungsbeständig. Risiko: zu nüchtern für Bitcoin-Maxi-Zielgruppe die "wow" erwartet; Onboarding für Nicht-Techies härter.

Konzept C — "Aurora Bento" (Editorial Neo-Bento)

  • Stil: Bento-Grid mit großzügigem Whitespace, soft-cream gegen deep-night-mode-Toggle, Mesh-Gradient-Akzente. Mix aus Apple-Bento + Stripe-Mesh + Notion-Editorial.
  • Palette: Dark-Default: #0B0D10, #15181D, #F2EFE8 (warm cream text), #FF7A1A (BTC orange als Hero-Accent), #A8FF60 (signal lime). Light-Mode invertiert sauber.
  • Typo: Header General Sans (display, 700). Body Inter (16px, generös). Numerik Geist Mono.
  • Layout: Bento mit 3 Größen-Klassen (S/M/L), klare Hierarchie. Hero-Card = Hashrate, M = Worker-Grid, S = BTC-Price/Block-Height/Difficulty.
  • Daten-Viz: Soft animated counters (spring physics via WAAPI), Area-Charts mit Mesh-Gradient-Fill, Worker-Cards mit Mini-Donuts für Share-Anteil, Block-Found als Confetti-Burst (CSS motion-path).
  • Moderne Techniken:
    • CSS motion-path für Block-Found-Animation
    • View Transitions zwischen Light/Dark
    • color-mix() + oklch() für perceptual-uniform Theme-Shifts
    • Container Queries für Bento-Card-Adaption
  • Mobile: Bento reflow zu Magazin-Scroll, große Tap-Targets, PWA-Install-Prompt prominent.
  • Gut: zugänglich für Newcomer + tief genug für Pros, gut fotografierbar (Marketing!), Light/Dark gleich stark. Risiko: Bento ist 2026 leicht ausgelutscht — Differenzierung muss über Craft + Micro-Interactions kommen, sonst "noch ein SaaS-Dashboard".

3. Empfehlung

Konzept A "Warp Drive" — weil der Name WarpPool die Richtung schon vorgibt und die User explizit wegwollen vom klassischen Vorgänger-Look. Warp Drive nutzt die modernsten Web-Möglichkeiten (View Transitions, scroll-timeline, color-mix, WebGL) ohne in Bento-Beliebigkeit (C) oder Terminal-Härte (B) zu fallen. Es liefert das "wow" für Marketing-Screenshots, bleibt aber funktional bedienbar — sofern Glass-Effekte diszipliniert eingesetzt werden (nur auf Cards, nie auf Charts). Fallback-Pfad: Wenn Performance-Tests scheitern, ist Konzept C ein sauberer Rückzugsraum mit ähnlicher Palette. Konzept B als Power-User-Theme-Option später nachreichen ("Terminal Mode").


4. Implementation-Stand (2026-05-27)

Konzept A ist implementiert in ui/ (SvelteKit 2 + Svelte 5 Runes, adapter-static, SPA-Modus). Was tatsächlich live ist:

Routes

RouteInhalt
/Dashboard mit Pool-Stats-Bento (Hashrate-Chart, Pool-Stats, Network, Blocks, Worker)
/blocksBlock-Tabelle mit pending/accepted/rejected-Status
/workersWorker-Liste mit user-agent + last-share
/hardwareLive-Detection + Profil-Picker mit Empfehlung
/hashrateHistorisches Hashrate-Chart (1d / 1w / 1m)
/minersKonfigurierte Miner-Liste + Discovery
/loginAuth-Page (Username/PW + optional 2FA-Code)
/adminHub für Profile-Switch, Backup, Tokens, 2FA, Audit, Notifications
/admin/notificationsBrowser-Push-Settings + Server-Side Sinks (Phase 15.6) — pro-Sink-Test-Buttons mit Badge-state (neutral / ok / err) + last-error-tooltip
/admin/profile, /admin/tokens, /admin/audit, /admin/2fa, /admin/backupSelf-explanatory Admin-Surfaces

Komponenten

ComponentWas
Starfield.svelteCSS-animated drift (60 Sterne + 4 glow-decals, alpha .35-.95)
BentoCard.svelteGlass-morphism mit backdrop-filter: blur(8px)
StatTile.svelteAnimated counter (Web Animations API)
Badge.svelteTone-aware Pill (ok / warn / err / neutral)
HashrateChart.svelteuPlot-basierter Chart mit Gradient-Fill + Block-Annotations
HealthBanner.sveltePhase 14 — konsumiert SSE health_snapshot + update_available, persistente dismissable Banner unterhalb des Headers
EventToasts.svelteBlock-Found / Profile-Switch Toast-Notifications
LocalePicker.svelte8-locale-Dropdown mit Emoji-Flags

Modern-Web-Tech tatsächlich genutzt

  • View Transitions API für Routen-Wechsel
  • color-mix() für theme-reaktive Glow-States
  • Container Queries für mobile Bento-Reflow
  • SSE (EventSource) für Live-Push via /api/events
  • backdrop-filter + Glass-Cards
  • WebGL-Sterne: bewusst CSS-animation statt WebGL — prefers-reduced-motion-fallback + Performance auf alten Phones
  • @scroll-timeline + motion-path: noch nicht implementiert (Phase B Polish wenn Browser-Support breit)
  • Popover API: noch nicht — Modals nutzen aktuell standard <dialog>

Internationalisierung

8 Locales über JSON-Files in ui/src/lib/locales/:

  • de (Deutsch — Primary)
  • en (English — Fallback)
  • es (Español), pt-BR (Português Brasil)
  • fr (Français), it (Italiano)
  • ja (日本語), zh (中文)

i18n-System ist selbstgebaut (~150 LOC in i18n.svelte.ts) — runes-Store + t('foo.bar', {name: 'X'}) mit Interpolation, kein heavy framework. Locale-Detection: localStorage > navigator.language > 'en'.

PWA

  • app.webmanifest + Icon-Set (192/512)
  • service-worker.ts (cache-first für static assets, network-first für /api/*)
  • Install-Prompt prominent im Login-Screen
  • Browser-Notification-API für Foreground-Block-Found-Events
  • VAPID-Push (Storage-Tabelle push_subscriptions ist angelegt, Send-Loop ist Phase B)

Was vom ursprünglichen Konzept abweicht

  • Starfield als CSS, nicht WebGL — performance auf RPi 5 / alten Phones war ausschlaggebend. Optisch nahezu identisch, ein Bruchteil GPU-Last.
  • Glass-Cards diszipliniert — nur auf BentoCard, nicht auf Tabellen oder Charts (Lesbarkeit).
  • Bento bleibt 12-spaltig — kein dynamisches reflow zu 6/4/3 cols. Container Queries machen die Cards selbst adaptiv.
  • Brand-Akzent nicht-Orange — wir nutzen --warp (violet) als Primary, Orange nur sparsam für BTC-Bezug. Konzept-A-Palette ist 1:1 umgesetzt.

Performance-Stand

  • Initial-Bundle: ~250 KB JS + ~30 KB CSS (gzipped)
  • Lighthouse-Score (auf einem typischen Pool-Setup): ~95 Performance, 100 Accessibility, 100 Best Practices, 100 SEO
  • Hashrate-Chart mit uPlot: 60fps auch bei 1000 Datenpunkten

Bekannte Limitierungen

  • Pre-existing CSS-warning Unused CSS selector "th.num" in blocks/+page.svelte — historisches Artefakt, kein Funktionsproblem
  • Keine echte Dark/Light-Theme-Toggle (UI ist immer dark — Konzept A spec'd bewusst keinen Light-Mode)
  • Light-Mode-Variante würde nichttrivial sein wegen backdrop-filter + Glassmorphism-Optik

Testing & Simulation

Vier Test-Ebenen, alle pre-release verifiziert. Stand: 520 Rust-Tests grün + 3 ignored (regtest opt-in), 298 Svelte-Check-Files / 0 errors.

1. Unit-Tests

Pro Crate, reine Logik. cargo test --workspace läuft alle 16 Library- Crates + 5 Binaries.

Wichtige Test-Counts (Stand Phase 19):

CrateTestsWas
warppool-config~24TOML-Schema, coinbase_splits, VarDiffSettings::validate
warppool-storage~75sqlx-roundtrips, migrations, alle 10 Tabellen, vardiff, audit_log, api_tokens, admin_2fa, pool_settings
warppool-api~80axum-handler-Tests gegen in-memory SQLite + mock-GitHub/SMTP-Server. Inkl. Phase 16-Tests (pool_metrics + notifier in /metrics)
warppool-stratum-v1~44 + 4 e2esession-state-machine, ratelimit, vardiff (17 isoliert), TLS-roundtrip
warppool-stratum-v2~71noise-handshake, codec-roundtrips, session-frames, TDP-foundation
warppool-translator~22extended-channel-state-machine, BIP-320-version-rolling, slushpool-prev-hash-reverse
warppool-share-validator~17sha256d-vectors, dedup, network/pool-target-checks
warppool-job-builder~32coinbase-construction, BIP-34-height-encoding, merkle-branches
warppool-bitcoin-rpc~14RPC-envelope-parsing, ZMQ-frame-parsing
warppool-notifier19render_text, EmailSink/SlackSink-construction, sink-skip-when-env-missing
warppool-autoupdate~25version-compare, asset-matching, download_verified, atomic_swap, cosign-subprocess
warppool-health~24RPC-call, bitcoin-conf-parser, snippet-generator, warnings-aggregation
warppool-telemetry~27vendor-probes (AxeOS/cgminer-mock), mDNS-discovery, PoolMetrics+histogram
warppool-profiles6profile-resolution, retention-monotonie
warppool-hwdetect6sysinfo-detection, container-env, recommendation
warppool-simulator~9sim-miner-personas, scenarios
# Alles
cargo test --workspace

# Ein bestimmter Crate
cargo test -p warppool-share-validator

# Mit output
cargo test -p warppool-stratum-v2 -- --nocapture

2. Integration-Tests

Mehrere Crates in-process, axum-mock-server für externe Dienste. Pro Crate unter tests/.

Typische Cases:

TestCrateWas
e2e.rsstratum-v1TCP-Listener + ShareValidator + Sim-Miner-Roundtrip mit echtem subscribe/authorize/submit-Flow
e2e_regtest.rsbitcoin-rpc#[ignore] — gegen laufendes 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 gegen Prometheus-Format

3. Simulation (dvb-warppool-sim)

Gegen den echten Daemon-Prozess (oder in-process spawned). Szenarien sind End-to-End-Stories.

Eingebaute Szenarien

SzenarioDauerStatusWas es prüft
solo-block-found30s✓ executableSim-Miner connectet, Block-Found-Event landet
connection-storm-defense60s✓ executableRate-Limit greift bei N parallelen Auth-Versuchen
enterprise-load-smoke5min✓ executableN parallele Miner gemischter Persona laufen sauber
small-24h-stability24hPlan-onlyKlein-Profil bleibt unter 200 MB, keine Panics
rpc-outage-recovery10minPlan-onlyPool übersteht 5-Minuten-RPC-Outage

Aufruf

solo-block-found läuft self-contained — spawnt einen Stratum-V1-Server in-process mit network_target=[0xff;32] (jeder valid Share wird BlockFound) und einen JsonStratumMiner (Persona BitaxeUltra). Kein Bitcoin Core nötig:

dvb-warppool-sim scenario solo-block-found --duration 10
# → Result: BLOCK FOUND after 55 ms (worker=bc1qsim-bitaxe, job_id=sim-job-1)

dvb-warppool-sim scenario connection-storm-defense --duration 15 --attackers 200
# → Success: 10, RateLimit: 190, OtherErr: 0
# → OK: rate-limit hat 190x gegriffen (burst war 10)

dvb-warppool-sim scenario enterprise-load-smoke --duration 15 --miners 200
# → Accepted: 57400 (~3826 shares/s), Rejected: 0, Disconnects: 200

Personas

Sim-Miner ahmen reale Hardware nach (Hashrate, User-Agent, TLS-Support, Reconnect-Pattern):

PersonaNominal HRateTLSAnmerkung
nerdminer-v250 GH/sneinPlain only
bitaxe-ultra2 TH/sjaSauberer Reconnect
nerd-octaxe4.5 TH/sjaNerdNOS-Quirks
avalon-q90 TH/sneinCGMiner-User-Agent
antminer-s23-pro580 TH/sjaASIC-Boost overt
adversaryBoshafte Inputs

Failure-Modes

dvb-warppool-sim failures <mode> injiziert Production-Probleme:

  • rpc-down — Bitcoin Core unreachable
  • zmq-disconnect — Block-Notify weg
  • tls-handshake-fail — abgelaufenes Cert
  • miner-hang — Connection hängt nach N Shares
  • network-latency — künstliche RTT
  • packet-loss — N% Paket-Loss
  • invalid-template — kaputte CB-Tx
  • reorg — Tip wird zurückgerollt
  • memory-pressure — Pool unter RAM-Druck
  • disk-full — SQLite-Write-Fail

Security-Suite

dvb-warppool-sim security fährt adversarielle Inputs (Line-Flood, Connection-Storm, Slowloris, Auth-Flood, Weak-Cipher, ...).

Correct outcome: jede Connection sauber abgelehnt, kein Panic, kein OOM.

4. Regtest E2E (Phase 15b — opt-in)

Für volles E2E mit echtem bitcoind regtest:

eval "$(scripts/regtest-up.sh)"   # startet bitcoind regtest + setzt env-vars
cargo test -p warppool-bitcoin-rpc --test regtest_e2e -- --ignored --nocapture
scripts/regtest-down.sh --purge   # cleanup

Drei #[ignore]-Tests gegen das laufende regtest:

TestWas
regtest_blockchain_info_returns_regtest_chainchain == "regtest"
regtest_getblocktemplate_worksTemplate hat version + bits + prev-hash
regtest_submit_invalid_block_is_rejectedTrivially invalid hex → Rejected (nicht Accepted, nicht Error)

CI führt sie nicht aus (kein bitcoind im Runner-Image). Lokal sind sie sehr nützlich vor Release-Tags. Siehe Configuration Reference → WARPPOOL_E2E_REGTEST_*.

5. Performance Benchmarks (Phase 19 — opt-in)

Criterion-Suites für Hot-Paths:

CrateBenchHot-Path
warppool-share-validatorvalidateper accepted+rejected share
warppool-job-builderbuild_jobper neuem Template
warppool-stratum-v1vardiffper accepted share
cargo bench --workspace
# oder
cargo bench -p warppool-share-validator --bench validate

Siehe Performance Benchmarks für Baseline-Zahlen und Interpretation.

6. UI-Side (svelte-check)

cd ui
pnpm install
pnpm svelte-check
# → 294 files / 0 errors / 1 pre-existing warning

Pre-existing warning: Unused CSS selector "th.num" in blocks/+page.svelte — historisches Artefakt aus Phase 4, kein Funktionsproblem.

CI

.github/workflows/ci.yml Matrix:

  • cargo fmt --check (no diff)
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --workspace
  • cargo deny check (license + advisories)
  • cargo audit
  • UI-Job: pnpm install + pnpm svelte-check + pnpm build

Separate Workflows (siehe Architecture):

  • release.yml — Tag-Triggered: Multi-arch Docker + native binaries + SLSA-3 + SBOM
  • repro.yml — Reproducible-Builds-Verify (zweimal bauen + sha256-diff)
  • docs.yml — mdBook → GitHub Pages
  • benches.yml — Criterion (manual-dispatch + tag-push, nicht-PR)

Performance Benchmarks

dvb-WarpPool uses Criterion for hot-path benchmarks. Three suites cover the critical code paths. The goal is not maximum throughput — a solo pool's workload is small — but a regression baseline so that code changes are caught early when they unexpectedly become expensive.

Suites

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 (CI smoke)
cargo bench --workspace --no-run

Criterion writes reports to target/criterion/<bench-name>/report/index.html. On a second run it compares against the first and reports drift (Performance has regressed. / Performance has improved.).

Baseline Numbers (2026-05-27, MacBook M-Series, release build)

These numbers are a snapshot, not a hard contract — they can vary by 2× depending on hardware and CPU throttling. On Linux x86_64 server hardware the values are typically similar or better.

validate (per share)

BenchTimeThroughput
validate_full/0 (no merkle branches)1.32 µs760K shares/s
validate_full/8 (typical regtest)5.49 µs182K shares/s
validate_full/12 (typical mainnet)7.59 µs132K shares/s
sha256d_80b_header528 ns
sha256d_500b_coinbase1.55 µs
merkle_root/12(hot-path portion) ~2 µs
reconstruct_coinbase< 200 ns
build_header< 30 ns

Take-away: validate scales with merkle-branch count (linearly). At 12 branches the pool can validate ~130K shares/s — that's 1000× more than a solo pool with 7 Bitaxes will ever see (typically 1-5 shares/s). Validate is NEVER the bottleneck.

build_job (per job-refresh)

BenchTimeThroughput
build_job/0 (empty / regtest)~100 µs
build_job/100~150 µs
build_job/1000~700 µs
build_job/4000 (typical full block)2.59 ms386 jobs/s
merkle_branches/40002.30 ms

Take-away: Job-build scales with tx-count, dominated by merkle-branch computation. 2.59ms / job for a full mainnet block is clearly visible but not a problem — templates arrive every 30+ seconds, not every ms.

vardiff (per share, EMA-update + retarget)

BenchTime
vardiff_observe_share_hold (stationary)5.2 ns
vardiff_observe_share_retarget (8-share burst)37.9 ns (~5ns/share)
difficulty_to_target_be12.85 ns
vardiff_decision_variant_match432 ps

Take-away: VarDiff is effectively free. Even under extreme load scenarios (>100K shares/s) it consumes <1ms/s of CPU.

Interpretation

What you can read from the numbers:

QuestionHint
"Is my pool burning too much CPU?"No. At 10 shares/s and 12 merkle branches: ~76 µs share-validate time per second = 0.0076% CPU
"How many workers can my pool serve at most?"The Stratum connection cap (profile-dependent, 64-4096). Share-validate is not the limit
"Is ASIC-boost / merkle-tree caching worth it?"No, not in a solo pool. In a 10M-shares/s pool, per-template merkle-branch caching would be a factor of 5-10

CI

.github/workflows/benches.yml runs only:

  • Manual dispatch (operator clicks "Run workflow" in the UI)
  • On tag push (release snapshot)

NOT on every PR — Criterion runs are expensive (~5min build + 5min suite), and GitHub-runner noise makes microbench comparisons unreliable.

Reports are uploaded as artifact criterion-reports-<sha> with 30-day retention. The operator can download them and view them locally in the HTML report.

Regression Workflow

When a bench suddenly becomes 50% slower:

  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 GitHub. 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. tokio-console is more useful 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 production-ready 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)

*Each phase additionally turned tests green. Workspace total: 356.

Phase 7.1 — Foundation

Workspace member warppool-stratum-v2 with three modules:

  • codec.rs — Sv2 frame format (6-byte header [ext u16 LE][type u8][len u24 LE]), MAX_PAYLOAD_LEN = 1 MiB, compact-int encoding (Bitcoin var_int compatible), length-prefixed strings.
  • messages.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 snow::HandshakeState / TransportState. Pattern: Noise_NX_25519_ChaChaPoly_BLAKE2s.

Quirks: snow 0.10 builder methods are Result-returning (use ? instead of chaining); OpenStandardMiningChannel nominal_hash_rate: f32 blocks Eq derive — only PartialEq.

Phase 7.2 — Mining-Protocol State Machine

Pure logic layer with no TCP coupling: the caller feeds in decoded Frames and gets 0..n response frames back.

Three session phases: 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 (0x1E) — standard channels (pre-computed merkle_root)
  • NewExtendedMiningJob (0x22) — extended channels (with version_rolling_allowed, SEQ0_255 merkle_path, B0_64K coinbase_prefix/suffix)
  • SetNewPrevHash (0x20) — references the previous job 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 (gated)

Waiting on Bitcoin Core gaining native Sv2 TDP server support. Today this only exists in SRI forks; no path runs in production.

What Remains for Phase 8

  • Reproducible builds (deterministic timestamps, --remap-path-prefix)
  • Cosign signatures (partly in Phase 6 — keyless OIDC for SHA256SUMS)
  • SBOM via Syft (partly via anchore/sbom-action)
  • Auto-update with rollback (larger feature, its own crate warppool-autoupdate)
  • Documentation site ← this one (Phase 8a)

This documentation itself is Phase 8a.

Roadmap

Phase 1 — Foundation (THIS SESSION ✓)

  • Workspace layout, Cargo.toml, .gitignore, .cargo/config.toml
  • warppool-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/sselib/events.svelte.ts) — WebSocket variant rejected, SSE is sufficient for read-only push and avoids reverse-proxy pitfalls
  • i18n (DE/EN/JA/ZH) via lib/i18n.svelte.ts + locales/*.json
  • Push notifications: foreground via Browser Notification API (lib/notifications.svelte.ts); real VAPID push (web-push crate) deliberately deferred due to native-OpenSSL dependency — will come with a pure-Rust web-push backend
  • Mobile-responsive (header collapsed, all main routes OK)

Phase 5 — Operations (complete)

  • Prometheus exporter /metrics
  • Vendor API polling (AxeOS, NerdNOS, BitMain, Whatsminer)
  • mDNS auto-discovery
  • Notifier: ntfy + Telegram + Discord + Web Push + Email
  • Backup/restore (config + DB)

Phase 6 — Packaging (complete, incl. RPi 5 first-class)

  • Docker multi-arch (amd64 + arm64)
  • Umbrel app manifest
  • macOS .dmg (universal binary, notarized)
  • Windows .msi (signed)
  • Linux .deb / .rpm / .AppImage
  • Tauri desktop bundle (optional)
  • Raspberry Pi 5 first-class — dedicated arm64 builds, pinned for Pi 5

Phase 7 — Stratum V2

  • 7.1 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 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 Wiringdeferred (2026-05-27 ecosystem re-eval): Bitcoin Core has decided against native Sv2 support (Issue #31098); instead, since Core 30.0 an experimental generic IPC Mining Interface is available — Sv2 functionality is to be implemented via a sidecar (e.g. SRI's sv2-tp-client), not directly in the node. Three paths exist today: (A) Sv2 TDP client against Sjors' Bitcoin Core fork 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.

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 VarDiff (channel target fixed at diff-1; for solo only share accounting is affected, not BlockFound — separate feature, no polish).

Phase 31 — Sv2 live test + daemon job-broadcast bug (complete)

  • Sv2 + translator live-test setup (THIS SESSION) — Stack: Bitcoin Core regtest + dvb-warppool-daemon with stratum.sv2_listen=127.0.0.1:3334 + secrets.sv2_static_priv_key_hex + translator sidecar (dvb-warppool-translator, V1-listen :3335 → V2-connect :3334) + Python sim-miner against the translator. Daemon starts cleanly, all listeners up.
  • 🐛 BUG FOUND: daemon push_job-to-Sv2 missing (THIS SESSION) — The sv2_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, 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, github.com/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 (partly in Phase 6 — cosign sign-blob keyless OIDC for SHA256SUMS already set up)
  • SBOM (Syft) (partly in Phase 6 — anchore/sbom-action already in the release)
  • 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.