Architecture
dvb-WarpPool is a Rust workspace with 18 library crates + 6 binaries. A clear layering — from pure schema crates without async at the bottom up to the async-orchestrated binaries on top — keeps the system testable and lets sub-crates run standalone (e.g. the translator as a sidecar without the pool daemon).
Crate Overview
Schema + foundation (no async)
| Crate | What |
|---|---|
warppool-profiles | admin profiles — capacity hints + defaults. TOML values klein / mittel / gross / enterprise (Small ≤5 / Medium ≤20 / Large ≤100 / Enterprise >100 miners). These are recommendations for tuning threads, RAM target, retention and poll intervals — not hard caps; all four are hot-switchable at runtime |
warppool-config | TOML schema (PoolConfig + sub-configs + secrets) |
warppool-hwdetect | hardware detection via sysinfo → profile recommendation |
Core mining layer
| Crate | What |
|---|---|
warppool-bitcoin-rpc | JSON-RPC + ZMQ subscribe for Bitcoin Core |
warppool-bitcoin-ipc | Cap’n Proto mining client for Bitcoin Core >= 31 (low-latency template source; TemplateSource trait shared with the GBT path). Selected via [node].template_source = "ipc", or "auto" (probe the socket once at start, else GBT; DVB_WARPPOOL_TEMPLATE_SOURCE env override for containers); the ipc_template_stale health watchdog is the fallback signal. GBT stays the default, and Core’s own IPC mining interface is still experimental. Also ships the ipc-latency-probe binary |
warppool-job-builder | coinbase tx (BIP34 height, SegWit commitment, BIP54-forward-compatible locktime) + merkle tree + Stratum job construction |
warppool-share-validator | share PoW check + block-found detection |
warppool-stratum-v1 | TCP listener, session state machine, VarDiff |
warppool-stratum-v2 | NOISE handshake, binary framing, mining subprotocol |
warppool-translator | V1↔V2 sidecar (a pool can offer Sv2 only; V1 miners connect via the translator) |
Storage + API
| Crate | What |
|---|---|
warppool-storage | SQLite via sqlx, all tables (idempotent schema init, no file migrations) |
warppool-api | axum HTTP API (REST + SSE stream + static UI serving) |
Operational subsystems
| Crate | What |
|---|---|
warppool-health | Bitcoin Core multi-RPC health + bitcoin.conf parser + snippet generator |
warppool-autoupdate | version parser + forge release client + atomic_swap + cosign hook |
warppool-notifier | push sinks (ntfy/Telegram/Discord/Slack/email) + counter metrics |
warppool-telemetry | vendor API probes (AxeOS, NerdNOS, Avalon, …) + mDNS discovery + HTTP sweep + PoolMetrics |
warppool-uninstall | cross-platform uninstall plans (per-OS path tables, marker-gated Bitcoin Core targets) |
warppool-tune | dvb-WarpTune auto-clock regulator for AxeOS miners: per-board frequency/voltage ladder, guardrailed decision logic, pool coupling (reject feed, energy mode), JSONL journal, read-only snapshot endpoint |
Tools
| Crate | What |
|---|---|
warppool-simulator | sim miners (vendor personas) + sim node + scenarios |
Binaries
| Binary | Purpose |
|---|---|
dvb-warppool-daemon | the pool — orchestrates all subsystems |
dvb-warppool-cli | operator tools (hash-password, token-create, set-profile, check-update, …) |
dvb-warppool-setup | first-run wizard (axum, embedded HTML) |
dvb-warppool-translator | V1→V2 sidecar (clap CLI, can run as a systemd service) |
dvb-warppool-sim | simulation runner (scenario list / run) |
dvb-warppool-mac-launcher | macOS app-bundle launcher (caffeinate wrapper, daemon supervision) |
dvb-warppool-tune | dvb-WarpTune sidecar CLI (run, journal, probe, ladder, check-config; launchd agent via scripts/setup-warptune.sh) |
Data Flow (happy path)
+---------------------+
| Bitcoin Core |
| (mainnet / regtest) |
+---------------------+
| RPC | ZMQ pub
v v
+---------------------+
| bitcoin-rpc |
| - getblocktemplate |
| - submitblock |
| - hashblock watch |
+---------------------+
|
v
+---------------------+
| job-builder |
| - coinbase splits |
| - merkle tree |
| - StratumJob |
+---------------------+
|
+-----------+-----------+
v v
+------------------+ +------------------+
| stratum-v1 | | stratum-v2 |
| TCP / TCP+TLS | | TCP+NOISE |
+------------------+ +------------------+
| |
v v
V1 miners (Bitaxe, V2 miners / translator
NerdNOS, AntminerS23)
| |
v v
submit (extranonce, nonce, ntime)
|
v
+---------------------+
| share-validator |
| - PoW check |
| - block detection |
+---------------------+
|
+-----------+-----------+
v v
+------------------+ +------------------+
| storage | | block-submit-loop|
| - record_share | | - assemble block|
| - vardiff_state | | - submitblock |
+------------------+ +------------------+
|
v on success
+---------------------+
| notifier |
| fire BlockFound |
+---------------------+
| | | | |
v v v v v
ntfy tg disc slack email
Daemon Task Topology
dvb-warppool-daemon is a Tokio multi-reactor with some two dozen
long-lived tasks plus per-connection tasks. All tasks share one
CancellationToken for graceful shutdown (Ctrl-C and, on Unix, SIGTERM).
+------- main() async -------+
| |
+- spawn ---------+----------+
| |
+-------+-------+ +------------------+
| Stratum-V1 | Stratum-V2 |
| accept loop | accept loop |
| (+ TLS) | (+ NOISE) |
+----------------+ +-----------------+
| |
per connection: session task
| |
v v
+---------------------------------+
| block_found_tx broadcast |
+---------------------------------+
|
+-------+--------+
| block_submit_ |
| loop |
+----------------+
|
Bitcoin Core RPC submitblock
|
+-------+--------+
| notifier.notify(BlockFound) |
+- spawn ---------+----------+
| job_refresh_loop |
| - Box<dyn TemplateSource>: |
| GBT (poll 30s + ZMQ tip |
| fast path) or IPC (waitNext) |
| - drain & build job |
| - tip_to_job timing → snapshot |
| - push to stratum handles |
+---------------------------------+
+- spawn ---------+----------+
| aggregate_loop (60s tick) |
| - storage.aggregate_5min |
| - SharesAccepted SSE event |
+---------------------------------+
+- spawn ---------+----------+
| health_check_loop (60s tick) |
| - check_bitcoin_health |
| - SSE HealthSnapshot |
| - RpcDown/Recovered notify |
+---------------------------------+
+- spawn ---------+----------+
| periodic_update_check (24h) |
| - Forge Releases API |
| - SSE UpdateAvailable |
+---------------------------------+
+- spawn ---------+----------+
| miner_poll_loop (vendor probes) |
+---------------------------------+
+- spawn ---------+----------+
| HTTP API (axum on :18334) |
| - REST endpoints |
| - SSE /api/events |
| - static UI from --ui-dir |
+---------------------------------+
(Later phases added further loops on the same pattern: UPnP lease renewal,
the Anker-bridge supervisor, rejected_flush_loop (2 s coalesced flush of the
RejectedShareBuffer fed by both stratum crates), backup_loop,
anomaly_check_loop, push_send_loop, webhook_dispatch_loop,
solar_poll_loop, the 30 s network-stats poll and the 10 min inbound-peer
scan; the daily energy aggregation runs hourly inside aggregate_loop.)
Template sources. job_refresh_loop is source-neutral: it consumes a
Box<dyn TemplateSource> (warppool-bitcoin-rpc::template_source). The GBT
source polls getblocktemplate on the refresh tick and refreshes immediately
on a ZMQ hashblock event; it also keeps a ZmqLiveness counter of tips it
only discovered by polling while ZMQ watchers were configured, which the
health tick turns into the bitcoin_zmq_hashblock_silent warning. The IPC
source (warppool-bitcoin-ipc) blocks in Core’s waitNext instead; ZMQ
watchers are not spawned in that mode. Every TemplateUpdate carries a
TemplateTiming (source ready → fetched → mapped); the loop derives
tip_to_job_ms for real tip changes and exposes fetch/map/tip-to-job in the
snapshot, /api/overview and /metrics.
Shared State
Tokio tasks share Arc<...> handles instead of global statics:
| Handle | Type | Used by |
|---|---|---|
notifier: Arc<Notifier> | shared via clone | block_submit_loop, health_check_loop, periodic_update_check, NotifierConnectionSink, API |
pool_metrics: Arc<PoolMetrics> | atomic counters | NotifierConnectionSink, BitcoinRpc, API /metrics handler |
event_bus: Arc<PoolEventBus> | broadcast::Sender | all subsystems publish; API → subscribers (SSE) |
storage: Arc<Storage> | sqlx pool | share recording, audit log, vardiff state, settings |
snapshot: Arc<RwLock<NetworkSnapshot>> | RwLock snapshot | job_refresh_loop writes; API reads |
profile_kind: Arc<RwLock<ProfileKind>> | hot-switchable | API admin route + display |
cancel: CancellationToken | propagation | all tasks (graceful shutdown) |
Storage Schema
Core tables from phases 1-15; the schema is created programmatically from a
bundled SCHEMA_SQL constant (CREATE TABLE IF NOT EXISTS …, run by
init_schema() in crates/storage/src/lib.rs) — there are no sqlx file
migrations. Later phases added the device tables
(miners, miner-telemetry raw/5min), the per-day energy history
(daily_aggregates), solar_excess_minute, the best-share tables
(best_shares, best_shares_all_time), wallet_prefs, pool_meta
(started_at) and admin_2fa_recovery on the same pattern.
| Table | Phase | What |
|---|---|---|
workers | 1 | worker list (user, first_seen, last_seen, last_user_agent, shares_accepted/rejected, blocks_found) |
shares_raw | 1 | last hour of raw shares — basis for hashrate computation |
shares_agg_5min | 1 | aggregated 5-minute buckets — hashrate chart data |
blocks_found | 1 | block history (worker, job_id, height, block_hash_be_hex, accepted, reject_reason) |
wallet_lifetime | 8 | per-wallet lifetime accumulator (sum_difficulty, expected_blocks, since) backing the odds panel’s expected_blocks/p_zero; written batched from the aggregate loop, watermarked by pool_settings.wallet_lifetime_last_rowid |
pool_settings | 2.5 | generic KV store (active_profile_kind, donation addresses, the v1.21.0 marquee banner text + expiry, etc.) |
vardiff_state | 2.5 | per-worker VarDiff snapshots (current_diff, ema_interval_secs, last_share_unix) |
audit_log | 3 | admin actions (actor, action, target, peer_ip, ok, details) |
api_tokens | 3.2 | persistent bearer tokens (token_hash, name, scope, last_used_at, revoked_at) |
admin_2fa | 3.3 | TOTP secrets per user (secret_base32, enabled) |
push_subscriptions | 1 | web-push subscriptions (PWA) |
An idle worker can be forgotten from the admin UI (DELETE /api/admin/workers, v1.38): its rows in workers, best_shares,
best_shares_all_time and vardiff_state (V1 key and :sv2 snapshot) go
in one transaction, together with the in-memory reject breakdown and the
Sv2 VarDiff snapshot cache; shares_* and blocks_found stay. A worker
with a live V1, TLS or Sv2 session is refused (an all-protocol refcount
fed by the connection sinks). The name is matched exactly as the miner
authorized it. Retention evicts workers rows but not best shares or
VarDiff memory; GET /api/admin/workers/orphans lists those leftover
names (union of best_shares, best_shares_all_time and
vardiff_state minus workers, the :sv2 key under its bare name) so
the admin page can remove them the same way.
Timestamp convention. DATETIME columns are written by SQLite itself
(CURRENT_TIMESTAMP → YYYY-MM-DD HH:MM:SS, space separator, UTC);
pool_settings string values are written by Rust as RFC3339 (T
separator). The two are not lexically comparable ('T' > ' '), so
cutoffs against DATETIME columns must be computed inside SQL
(datetime('now', '-N seconds')), never bound as Rust-formatted
strings — a v1.0.16 bug filtered out every active worker exactly this
way. The full rules live in the warppool-storage module docs.
The Sv2 Stack in Detail
Stratum V2 is a binary-framed protocol with a NOISE handshake in front. Two subprotocols matter in the pool context:
- Mining subprotocol (implemented) — channel-based, extranonce-aware, version rolling per BIP-320, per-channel set-target.
- Template Distribution Protocol (TDP; foundation in phase 7.6a,
wiring in 7.6b deferred) — replaces
getblocktemplatewith push-driven template updates straight from the Bitcoin node.
Standard vs. extended channels. Both are supported; pick by role.
Standard is header-only mining for end devices: the pool computes the
merkle root and ships ready-to-hash work — smaller frames, no merkle
work on the device. Extended hands the miner coinbase_prefix/suffix
plus the merkle path and full extranonce rolling, which is what proxies,
translators and farm aggregators need to manage their own search space
(our own translator opens an extended channel upstream for exactly that
reason). Extended costs a device bigger jobs and per-job merkle work on
the ESP32, and a single Bitaxe-class miner does not need the extra
search space — nonce + version rolling is ample for its hash rate.
That efficiency argument is real but it is not the deciding one.
Extended is also what lets the miner see the coinbase it is hashing,
which is the whole point of Sv2 for someone connecting to a pool they do
not run. AxeOS says as much in its own UI (“Extended Channels are
recommended for external pools — the pool sends the coinbase template so
the miner can verify it. Standard Channels should only be used with a
locally deployed SV2 node or JD client”), and it is a user-selectable
setting there (SV2 Channel Type, shown once the pool is set to V2),
not something the firmware decides alone. Not every firmware leaves the
choice open, though: NerdQAxe hard-wires extended (m_channelType = SV2_CHANNEL_EXTENDED in main/stratum/stratum_task_v2.cpp, with the
config read commented out), and AxeOS disables standard on BM1397.
Recommendation for miners
connecting to this pool: extended. Standard is the right pick when
the Sv2 endpoint is a node the miner operates itself. VarDiff converges
per channel either way; a difficulty difference between the two modes is
convergence state, not quality.
V1 miner ----TCP plain----> stratum-v1 server ----------+
|
V1 miner ----TCP+TLS------> stratum-v1 TLS server -------+
|
v
+------------------+
| job-builder |
| share-validator |
+------------------+
^
|
V2 miner ----TCP+NOISE----> stratum-v2 server (sv2_listen, +
reference deployment :3336)
|
V1 miner ----TCP--+ |
| |
v |
dvb-warppool-translator (sidecar) --TCP+NOISE--------+
- SetupConnection
- OpenExtendedMiningChannel with user_identity
- SubmitSharesExtended with miner extranonce
- receives NewExtendedMiningJob + SetNewPrevHash
- maps to V1 mining.notify + slushpool prev_hash + BIP-320 version rolling
The concrete wire format lives in crates/stratum-v2/src/messages.rs with
roundtrip tests. The NOISE handshake uses the SRI noise_sv2 stack
(secp256k1 + EllSwift + Schnorr-signed certificates, spec-conformant since
the Sv2 modernisation) — pure Rust, no OpenSSL.
Connection Lifecycle Hooks
Both Stratum servers expose an optional ConnectionSink trait. For
authenticated workers (V1: mining.authorize, V2: OpenChannel with
user_identity) on_authorized fires; on connection end on_disconnect.
The daemon implements a NotifierConnectionSink struct that implements
both traits. A per-worker debounce (default 30s) keeps flapping miners
from firing a notification on every reconnect. The same hooks feed the
live worker sets (TLS/Sv2 badges) and the peer-IP refcount map used by the
energy accounting (“Disconnected” devices are excluded from watts/kWh).
v1.Session::run
|
handle_authorize → connection_sink.on_authorized
|
... shares ...
|
loop exit → connection_sink.on_disconnect
v2.handle_connection
|
process_frame → channels().iter() → new user_identity?
| → on_authorized
... shares ...
|
loop exit → for each notified_user → on_disconnect
Event Bus + SSE
PoolEventBus is a tokio broadcast channel with PoolEvent variants:
| Event | Source |
|---|---|
BlockFound | block_submit_loop |
NewJob | job_refresh_loop |
SharesAccepted | aggregate_loop |
HealthSnapshot | health_check_loop (phase 13b) |
UpdateAvailable | periodic_update_check (phase 8e) |
ProfileSwitched | API admin profile hot-switch |
Heartbeat | SSE keepalive with the current subscriber count |
The API’s /api/events opens one SSE stream per client with the current
subscription set. The UI uses it for HealthBanner + UpdateBanner +
toast events.
Configuration Layers
/etc/dvb-warppool/
├── config.toml # non-sensitive settings
├── secrets.toml # chmod 600 — admin hash, jwt secret, rpc pass, sv2 key
└── pool.db # SQLite (path configurable)
Env vars for opt-in subsystems (see the
Configuration Reference):
WARPPOOL_HEALTH_CHECK_INTERVAL_SECONDS, WARPPOOL_AUTOUPDATE_REPO,
WARPPOOL_DISCONNECT_DEBOUNCE_SECS, sink-specific tokens, etc.
Lifecycle (daemon boot)
- Parse CLI (clap) + ENV
- Load
config.toml+ (optional)secrets.toml - Validate config (
mining.payout_addressrequired,ratelimitconstraints, …) - Init tracing subscriber (JSON when
logging.json) - Open SQLite + idempotent schema init (
CREATE TABLE IF NOT EXISTS …) - Resolve admin profile (persisted value in
pool_settingsbeats the config default) - Construct
BitcoinRpcwithwith_metrics(pool_metrics) - Probe RPC (warning on failure; the daemon still starts)
- Construct
Notifierfrom config - Construct
PoolMetrics(Arc-shared) - Build the initial job (or skip when RPC failed)
- Spawn the Stratum V1 listener (+ TLS listener when cert/key configured)
- Spawn the Stratum V2 listener when
sv2_listenis configured (the authority key is auto-generated intosecrets.tomlif missing) - Spawn job_refresh_loop (poll + optional ZMQ)
- Spawn block_submit_loop
- Spawn aggregate_loop (60s)
- Spawn health_check_loop when
WARPPOOL_HEALTH_CHECK_INTERVAL_SECONDS > 0 - Spawn periodic_update_check when
WARPPOOL_AUTOUPDATE_REPO+ interval > 0 - Spawn the HTTP API (axum on
status_listen) - Install signal handlers (SIGTERM → cancel.cancel() → all tasks shut down)
- The
tokio::mainevent loop runs until cancel
Crate Dependencies (DAG)
Simplified crate graph (not every edge; no cyclic deps):
profiles --+
hwdetect --+--> config
|
+--> storage
+--> bitcoin-rpc --+
+--> bitcoin-ipc |
+--> job-builder |
+--> share-validator
+--> stratum-v1 -----+
+--> stratum-v2 -----+
+--> translator -----+
+--> telemetry -----+
+--> notifier ------+
+--> autoupdate ----+
+--> health --------+
+--> uninstall -----+
+--> api -----------+
+--> simulator -----+
|
v
dvb-warppool-daemon
dvb-warppool-cli
dvb-warppool-setup
dvb-warppool-translator
dvb-warppool-sim
dvb-warppool-mac-launcher
Crates and their one-directional deps:
telemetry← none (foundation for metrics)bitcoin-rpc→telemetry(for the RPC latency histogram, phase 16.3)api→notifier+autoupdate+telemetry+storage+ …daemon→ all library crates plus runtime deps (tokio, sqlx, …)
Testing Strategy
Three layers, plus one extra operator-driven layer:
| Layer | What | Tool |
|---|---|---|
| Unit | pure logic per crate | cargo test -p warppool-<crate> |
| Integration | multiple crates in-process, axum mock servers | tests/ per crate |
| Sim | against the real daemon process, vendor personas | dvb-warppool-sim scenario <name> |
| Regtest E2E | against a real bitcoind regtest | scripts/regtest-up.sh + --ignored (4 tests, opt-in) |
As of v1.39.2: 1703 tests + 6 ignored, all green. UI side:
pnpm svelte-check → 376 files, 0 errors.
See TESTING.md for details + the simulator workflow.