Configuration Reference
dvb-WarpPool has two configuration files plus a set of env vars for optional subsystems:
config.toml— all non-sensitive settings (pool behavior, Stratum, VarDiff, notifier schema)secrets.toml— auth hashes, RPC credentials, Sv2 keys; always chmod 600- env vars — opt-in subsystems (auto-update, health-check interval, notifier secrets, debounce tuning)
Defaults are defined in crates/config/src/lib.rs; this page is the
operator’s view.
config.toml
[branding]
[branding]
status_brand_name = "dvb-WarpPool"
server_location = "self-hosted"
fiat_currency = "EUR"
pool_donation_address = ""
Controls the strings shown in the UI and in the Prometheus build_info
metric. No effect on functionality.
The announcement banner is not configured here. The marquee strip under the header is runtime state in the database, not a
config.tomlkey — see Announcement banner.
[legal]
[legal]
terms_url = "https://example.com/terms"
privacy_url = "https://example.com/privacy"
privacy_last_updated = "2026-02-14"
Optional. All fields default to empty. When empty (the solo default), the UI
shows no legal notice. When a public / commercial operator sets terms_url
and/or privacy_url, the dashboard footer renders a “By connecting mining
device(s) or software to this pool, you acknowledge…” notice linking to those
URLs (with the optional privacy_last_updated stamp). The legal texts
themselves are the operator’s responsibility. URLs are restricted to
http/https.
[privacy]
[privacy]
wallet_directory_public = true # false → Workers directory is operator-only
leaderboard_addresses_public = true # false → mask addresses in the best-shares leaderboard
Optional. Both default to true = the historical open behaviour, so existing
deployments are unchanged on upgrade. They control how much a public pool
exposes, matching the convention of established solo pools:
wallet_directory_public = falsehides the browsable Workers/wallet directory (/api/walletsand its nav link) from anonymous visitors — likesolo.ckpool.org, which has no public directory at all. Each per-address page/users/<address>stays reachable by direct link, and the authenticated operator still sees the full directory. Note: for the operator to keep seeing it, admin auth must be enabled (an unauthenticated request — including the operator’s own browser without a session — is treated as public).leaderboard_addresses_public = falsemasks the Bitcoin address in the public best-shares leaderboard server-side (e.g.bc1qsx…dvng, rig suffix dropped) — like AtlasPool. The authenticated operator still sees full addresses.
Neither affects payouts, block construction, or the per-miner opt-in privacy
flag (an address owner can already hide themselves via the signature-gated Owner
Area on their /users/<address> page); the two mechanisms are orthogonal.
[logging]
[logging]
debug = false
net_debug = false
json = true # JSON-formatted for Loki/Promtail; set false for readable stdout
Runtime override via env var: RUST_LOG=warppool=debug takes precedence
over the config setting.
[mining]
[mining]
payout_mode = "single" # "single" (default) | "per_worker"
payout_address = "bc1q..." # REQUIRED in "single" — block-reward recipient
pool_fee_percent = 0.0 # Default = no pool fee (solo)
pool_fee_address = "" # Required when fee_percent > 0
pooltag_prefix = "dvbprojekt-WarpPool" # max 64 bytes, written to coinbase scriptSig
operator_donation_address = ""
operator_donation_percent = 0.0
# Address-allowlist (Kern-Politur): payout addresses allowed to mine. Empty
# (default) = open to anyone. For a private / invite-only pool.
address_allowlist = ["bc1qalice...", "bc1qbob..."]
payout_mode — where the block reward goes:
"single"(default): the full reward goes to the single pool-widepayout_address. This is the ordinary solo / friends setup and is 100% backward-compatible."per_worker": each miner mines to their own address, taken from the Stratum login (<address>.<worker>). The pool never holds funds — a public, non-custodial 0%-fee solo pool. In this modepayout_addressis ignored and may be empty, andpool_fee_percent/operator_donation_percentmust be 0 (the full reward goes to the finder). A miner whose login is not a valid address for this network is rejected. Seedocs/design/per-worker-coinbase.md.
For multi-user-solo (hosting-service setup) in "single" mode: set
pool_fee_percent = 1.0 and pool_fee_address. Each of pool_fee_percent
and operator_donation_percent must be within 0–50, and their sum must not
exceed 99%.
When address_allowlist is non-empty, a miner whose payout address (everything
before the first . of the worker name) is not listed is rejected — so only your
own / invited miners can connect. Empty = open (the default). This gates both
transports: V1 mining.authorize, and Stratum-V2 channel-open (rejected with an
OpenMiningChannelError). On V2 a miner additionally needs the pool’s NOISE
authority key to connect at all.
Public wallet pages & the owner panel have no config. In per_worker mode
each miner gets a login-free page at /users/<address> (ckpool-compatible JSON
at GET /api/users/<address>). The opt-in owner panel — prove address ownership
by signing a challenge (BIP-137, or BIP-322 for Taproot), then toggle privacy
or set a per-wallet notify target — is driven entirely per miner and is
only reachable after that miner verifies ownership; there is no operator key
here to set. Do not
confuse this with two unrelated settings above: [legal] privacy_url is a
legal-footer link for the whole pool (terms/privacy policy), not a per-wallet
control, and address_allowlist gates who may mine, not who may see a page.
See Public 0% Solo Pool
and Notifications.
[node]
[node]
rpc_url = "http://127.0.0.1:8332"
zmq_hashblock_addr = "tcp://127.0.0.1:28332"
zmq_rawblock_addr = "tcp://127.0.0.1:28333"
rpc_cookie_path = "/home/bitcoin/.bitcoin/.cookie" # optional, alternative to user/pass in secrets.toml
template_source = "gbt" # "gbt" (default) | "ipc" | "auto" — see below
ipc_socket_path = "" # required when template_source = "ipc"
ipc_fee_delta_sats = 0 # ipc only; 0 = off
check_core_updates = false # opt-in: daily Bitcoin Core release check — see below
core_update_check_hours = 24 # fetch interval; 0 = off, max 168
The cookie takes precedence over user/pass in secrets.toml. Leaving the
ZMQ fields empty enables poll-only mode (slower but functional).
Outbound connections
For operators who audit what the daemon talks to: the only default-on outbound
call is the BTC price and difficulty-forecast loop against
https://mempool.space/api/v1/... (every few minutes; feeds the odds panel and
the next-retarget estimate, falls back to a local estimate when unreachable).
Everything else is opt-in: the pool’s own update check
(WARPPOOL_AUTOUPDATE_REPO, see Auto-update), the Bitcoin Core release
check below, and whichever notifier sinks, webhooks or energy providers you
configure yourself.
check_core_updates (default false) makes the daemon fetch the Bitcoin
Core release list once every core_update_check_hours (anonymous GET to the
GitHub releases API, user agent warppool-autoupdate/<version>, no
identifiers). The periodic health check then compares it with the node’s
getnetworkinfo.version:
- a newer release on the node’s own major line (31.1 → 31.2) raises the
suppressible health warning
bitcoin_core_update_available— those are bug-fix and security releases; - a newer major line (30.3 → 31.1) is only reported as
bitcoin.latest_core_releasein the health snapshot, never as a warning, so a node deliberately kept on an older line is not nagged.
The pool never updates Core itself; the warning tells the operator to use the Bitcoin Node app (Umbrel) or the system package manager. It is off by default because a pool must not contact a third party unless the operator asked for it. A failed fetch is logged at debug and keeps the last known list; it never produces a warning on its own.
template_source picks where block templates come from:
"gbt"(default) —getblocktemplateover RPC, refreshed on ZMQ block events plus thejob_refresh_secspoll. Works with every Bitcoin Core release."ipc"— the Cap’n-Proto mining interface of a multiprocess Bitcoin Core ≥ 31 (bitcoin-node -ipcbind=unix), which pushes a new template viawaitNextinstead of being polled. Core’s own IPC mining interface is itself experimental (its schema has changed between Core releases); the reference pool runs it in production, and"auto"below is the way to adopt it without committing — a socket that dies later is surfaced by theipc_template_stalehealth warning, and"gbt"is always the way back.
RPC stays mandatory in both modes (submitblock, health checks,
mintime). Config validation rejects any other value, and rejects
template_source = "ipc" without an ipc_socket_path.
template_source = "auto" probes the IPC socket once at start (if the
node comes up later, the health check keeps probing and raises
ipc_auto_fallback_socket_reachable once the socket answers — restart the
pool then) — the
configured ipc_socket_path, or on Umbrel the path derived from
APP_BITCOIN_IPC_SOCKET_RELATIVE_PATH under the read-only datadir mount — and
runs ipc when the node answers, gbt otherwise; the log states which and
why. Container deployments can force it with the environment variable
DVB_WARPPOOL_TEMPLATE_SOURCE=auto (also accepts gbt / ipc), so a
Bitcoin Core ≥ 31 with -ipcbind gets the fast path without a config edit.
The choice is made once per daemon start; a socket that dies later is
surfaced by the ipc_template_stale health warning, not by a runtime fallback.
ipc_socket_path is the -ipcbind Unix socket; Core’s default is
<datadir>/<network>/node.sock and a unix: prefix is accepted. Being a Unix
socket, pool and node must share a host or a volume (on Umbrel: the Bitcoin app
from v1.3.0 with the IPC Mining Interface toggle and Core v31.0 — mount the
datadir read-only and resolve the socket inside it).
ipc_fee_delta_sats (ipc only) additionally pushes a fresh template as soon as
the template’s fees rise by at least that many sats. 0 (default) = off, which
keeps the exact GBT cadence (tip change + job_refresh_secs poll). Values above
2100000000000000 (21 M BTC in sats) are rejected.
Both fields are also settable from the UI under Admin → Bitcoin Core → Template source, including a socket precheck.
[[node.backup]] — multi-node failover
Optional: any number of backup Bitcoin Core nodes. The [node] block is
the primary; each [[node.backup]] entry adds a fallback:
[[node.backup]]
rpc_url = "http://192.168.178.99:8332"
rpc_cookie_path = "/mnt/node-b/.bitcoin/.cookie" # auth option 1
zmq_hashblock_addr = "tcp://192.168.178.99:28332" # empty = no ZMQ from this node
Per-backup auth resolution, in order: rpc_cookie_path → user:pass
embedded in rpc_url (http://user:pass@host:8332 — note this lives in
config.toml, mind the file permissions) → the global
secrets.rpc_user/rpc_pass.
Failover semantics:
- Sticky with preferred primary. Calls run against the last working node; on transport, auth or warmup errors they hop to the next within the same call (the job loop never stalls). While running on a backup, roughly once a minute one call probes the primary first and switches back as soon as it answers.
submitblockgoes to ALL nodes in parallel — the block moment is the one instant where a dead node can cost real money; anyAcceptedwins.- ZMQ subscribes to every node that has a
zmq_hashblock_addr; the job loop deduplicates block events by hash, so whichever node reports first triggers the refresh. - Notifications: a node switch fires
node-failover(delivered to sinks that subscribed toon_rpc_down);rpc-downitself now means “no node reachable at all” — a pool running happily on a backup is degraded, not down, and the health snapshot carries a warning instead.
Verified live: primary kill → failover < 1 s with the job stream uninterrupted; primary restart → automatic return ≤ 60 s.
[server]
[server]
pool_listen = ":3333"
status_listen = ":18334"
status_tls_listen = ":18443" # optional
# api_readonly_listen = ":18339" # optional second listener: anonymous GETs only, no /api/auth or /api/admin
status_public_url = "https://pool.example.com" # for push notifications
trust_proxy_headers = false # only set true when the daemon runs behind Caddy/nginx
cookie_secure = false # set true when the UI is served via HTTPS
# job_refresh_secs = 90 # template poll interval, clamped to 10-600 (unset = --refresh-secs CLI default, 30)
# suppress_health_warnings = ["bitcoin_pruned"] # hide these health-banner warnings
# reuse_port = false # SO_REUSEPORT on all listeners — see Packaging → Zero-downtime restart
api_readonly_listen opens a second HTTP listener that serves only what the
public pool serves anonymously (GET on /api/overview, /api/workers,
/api/energy, /api/events, /metrics, …) plus the token-guarded sidecar
push (POST /api/tune/push), and refuses every other method plus
/api/auth/* and /api/admin/* outright. Use it when the main port sits
behind a login proxy but a sidecar on the LAN needs pool data. The setup
wizard never binds it, so a fresh install exposes neither the wizard nor the
first-run admin bootstrap there. Env override: DVB_WARPPOOL_API_READONLY_LISTEN.
status_public_url is required by the UI to build absolute URLs for web
push and email links. Leave empty when not running behind a reverse
proxy.
job_refresh_secs overrides the --refresh-secs CLI argument, whose default
is 30 s; values outside 10–600 are clamped, not rejected. Setting 90 s is the
recommended choice for ESP32-class miners: every mining.notify makes a
NerdMiner V2 (~250 KH/s) abort its hash loop and reload the coinbase, which
resets its displayed hashrate. ASIC miners don’t feel the trade-off either way.
The value only sets the periodic mempool refresh — new blocks always trigger an
immediate template via ZMQ or IPC waitNext.
suppress_health_warnings is an opt-in list of Bitcoin Core health-warning
keys the dashboard banner should not show — handy for a deliberately pruned
node (bitcoin_pruned). It is only the initial value: on first start it seeds
a database-backed list that you then manage under Admin → Health (or via
the “Hide permanently” button on the banner). Known keys include
bitcoin_pruned, bitcoin_no_inbound_listen, bitcoin_no_zmq_hashblock and
bitcoin_few_peers.
Running behind nginx or Caddy? See Reverse Proxy for
ready-to-paste configs (TLS termination, X-Forwarded-For, and the
/api/events SSE passthrough) and how trust_proxy_headers / cookie_secure
fit in.
[stratum]
[stratum]
stratum_tls_listen = ":3334" # optional, V1 TLS
stratum_password = "" # empty = address-format auth only
safe_mode = true # Conservative validator
tls_cert_path = "/etc/dvb-warppool/cert.pem"
tls_key_path = "/etc/dvb-warppool/key.pem"
tls_reload_secs = 300 # re-read cert/key on mtime change; 0 = off
sv2_listen = ":34254" # optional, Stratum V2
sv2_max_connections = 1024
max_connections_per_ip = 0 # V1 per-IP concurrent cap; 0 = off (Kern-Politur)
max_connections_per_ip bounds how many simultaneous V1 connections (plain +
TLS) a single source IP may hold — independent of the global connection cap.
0 (default) is off; loopback is always exempt. Useful on a public pool to stop
one host from hogging connection slots, while NAT’d home setups (many miners
behind one IP) usually leave it at 0.
Sv2 needs an authority key in secrets.sv2_authority_priv_key_hex
(32-byte x-only secp256k1, 64 hex chars). Since v1.0.15 the daemon
auto-generates and persists it on first start; only when the secrets
file is not writable is Sv2 disabled with a warning (the daemon still
starts).
When tls_cert_path/tls_key_path are unset, the daemon generates and
reuses a self-signed certificate under <data>/tls/ (SANs:
dvb-warppool, dvb-warppool.local, umbrel.local, localhost;
downloadable via GET /api/tls-cert and from the connect modal). If the
certificate cannot be generated, the TLS listener is disabled with a warning
and the daemon still starts. Point
the two paths at your own files to serve a CA-signed certificate
instead — needed for miner firmwares that only trust public CAs (see
Troubleshooting → TLS).
Renewal is handled by whatever issued the certificate (Caddy, certbot, your
PKI). The daemon watches both files and re-reads them when their mtime
changes, so an ACME renewal takes effect without a restart and without
dropping miners: existing connections keep the certificate they negotiated,
new handshakes get the fresh one. tls_reload_secs sets how often the check
runs (default 300 s; 0 disables it and restores the old
read-once-at-startup behaviour). If a reload fails — a half-written file
mid-renewal, say — the previous certificate stays in service and the next
check retries, so a bad write never takes the listener down.
[tuning]
[tuning]
max_saved_workers_per_user = 64
max_saved_workers_pool_total = 1024
active_worker_window_secs = 3600 # dashboard "active" window (default 1h)
Storage cap. Above these thresholds, old/idle workers are evicted (FIFO
by last_seen_at). active_worker_window_secs controls how long a
worker keeps counting as “active” on the dashboard after its last share.
[mining.auto_profile]
[mining.auto_profile]
enabled = false # opt-in
evaluation_interval_secs = 60
hysteresis_ticks = 5 # consecutive ticks before a switch
Automatic profile switching based on the active miner count. Disabled by
default; the admin UI’s manual hot-switch always works regardless.
Validation requires evaluation_interval_secs ≥ 1 and hysteresis_ticks ≥ 1.
[profile]
[profile]
kind = "klein" # klein|mittel|gross|enterprise (Small/Medium/Large/Enterprise); omit = auto
expected_miner_count = 5 # for auto: estimate used by auto-detection
The profile affects defaults and resource limits. Hot-switchable via
POST /api/admin/profile — the config value is only the fallback at first
start.
A profile switch also applies that profile’s VarDiff range
(initial_diff / min_diff / max_diff, shown per profile on the
Hardware page) to the running pool — the change takes effect for new
connections immediately and is persisted, so a restart derives the same
values. Earlier this was decoupled: the table showed a profile’s VarDiff
but the live pool kept the [vardiff] config values. The other VarDiff
fields (target_seconds_per_share, window, hysteresis, max_step,
idle_*) are not touched by a profile switch. If you set [vardiff]
values by hand in config.toml and never switch profiles at runtime,
your hand-set values stand.
Beyond VarDiff and the connection cap, a profile switch also drives the miner-poll interval, the retention TTLs, the Stratum-auth rate limit and the HTTP-API rate limit — live, and persisted so a restart re-derives them. The Hardware comparison table labels each row with which behaviour applies:
| Badge | Meaning | Values |
|---|---|---|
live | applies immediately, no restart | connection cap, VarDiff range, miner-poll interval, retention TTLs, Stratum-auth & API rate limits |
restart | takes effect on the next daemon start | db_pool_size (SQLite pools can’t resize live) |
guide | advisory only — a switch does not change it | stratum_threads / share_validator_threads (fixed at runtime), ram_target_mb (a hint), ws_update_interval (a UI-poll guide; the backend pushes via SSE) |
The same honesty rule as VarDiff applies to retention and the rate limits:
hand-set config.toml values for [retention] / [ratelimit] stand as long
as you never switch profiles at runtime. Retention maps the profile’s four
tiers (raw_shares / agg_5min / telemetry_raw, with agg_5min also
driving the 5-minute telemetry tier) onto the live eviction; the operator-only
fields (audit_log_secs, api_tokens_retired_secs,
push_subscriptions_stale_secs) are never touched by a switch. agg_1h has no
TTL eviction — those rows are rolled into daily_aggregates.
Profile defaults at a glance
The four profiles each bundle a set of presets for a pool size. This is the
same table shown on the Hardware page (where the active profile’s column is
highlighted). The Scope column is the live / restart / guide behaviour from
the badge table above. The headers below use the profiles’ English display
names; the matching config kind values are klein (Small), mittel
(Medium), gross (Large), and enterprise.
| Setting | Scope | Small | Medium | Large | Enterprise |
|---|---|---|---|---|---|
| Recommended miners | — | ≤5 | ≤20 | ≤100 | >100 |
| Deployment | — | Solo | Solo + friends | Community¹ | Service¹ |
| Connection cap | live | 16 | 64 | 256 | 4,096 |
| RAM target | guide | 128 MB | 512 MB | 2 GB | 8 GB |
| Stratum threads | guide | 1 | 2 | all cores | all cores |
| DB pool | restart | 4 | 8 | 16 | 32 |
| UI refresh | guide | 5 s | 2 s | 1 s | 1 s |
| Miner polling | live | 30 s | 15 s | 5 s | 1 s |
| VarDiff initial | live | 4,096 | 32,768 | 1,048,576 | 16,777,216 |
| VarDiff min | live | 1,024 | 4,096 | 65,536 | 524,288 |
| VarDiff max | live | 262,144 | 4,194,304 | 67,108,864 | 1,073,741,824 |
| Retention — raw shares | live | 1 d | 7 d | 30 d | 90 d |
| Retention — 5-min agg | live | 7 d | 30 d | 90 d | 1 y |
| Retention — 1-h agg | guide | 90 d | 1 y | 5 y | 10 y |
| Retention — miner telemetry | live | 1 d | 7 d | 30 d | 90 d |
| Stratum auth / min | live | 60 | 120 | 600 | 6,000 |
| API / min | live | 120 | 600 | 3,000 | 60,000 |
¹ Community and Service assume commercial extensions outside the OSS scope — see Scaling Tiers.
What each row means:
- Connection cap — maximum simultaneous miner connections; beyond it new ones are refused.
- RAM target — a rough memory guideline for the size class, not a hard cap (the pool does not pre-allocate it).
- Stratum threads — Tokio worker threads for the Stratum listener
(
all cores=num_cpus); fixed when the runtime starts. - DB pool — SQLite connection-pool size (more = more concurrent DB access);
fixed at startup, hence
restart. - UI refresh — a guideline for how often the dashboard updates; the backend pushes live events over SSE anyway, so it is advisory only.
- Miner polling — how often the pool polls each miner’s telemetry (temperature / watts / volts).
- VarDiff initial / min / max — the per-profile defaults the running VarDiff
starts from and stays within. Sub-1 difficulty stays possible for tiny devices
via
suggest_difficulty, regardless ofmin. - Retention — how long each data class is kept before automatic eviction; larger profiles keep more history.
- Stratum auth / API per min — per-IP rate limits; loopback and the LAN are
exempt (see
[ratelimit]).
[vardiff]
[vardiff]
enabled = true
target_seconds_per_share = 15.0
window = 8
min_diff = 1.0
max_diff = 65536.0
retarget_after_n_shares = 4
hysteresis = 0.20 # 20% — a new diff that deviates > 20% from the old one triggers a retarget
max_step = 4.0 # max 4× per retarget
initial_diff = 1.0
idle_decay_enabled = true # time-driven decay for workers that went quiet
idle_grace_factor = 20.0 # × target_seconds_per_share of silence before the first decay step
idle_step_factor = 8.0 # × target between further steps while still quiet
significance_gate = true # only retarget when the deviation is statistically significant (default on since v1.11.0)
gate_confidence = 0.90 # confidence band of the significance gate
EMA-based, with persisted snapshots in the vardiff_state table. Tests:
crates/stratum-v1/src/vardiff.rs.
Significance gate. significance_gate (on by default) makes VarDiff
retarget only when the EMA share-rate deviation is statistically significant for
the number of shares seen — formally when
|ema/target − 1| · √window > z_crit(gate_confidence). A worker producing few
shares per window (NerdMiner, NerdNOS and other sub-MH/s / ESP-class miners) has
a Poisson-noisy observed rate (≈ 1/√N), and a fixed hysteresis band reads that
noise as a real hashrate change — flapping the difficulty and flooding the miner
with mining.set_difficulty (the visible ESP32 display flicker). The gate holds
the difficulty until the deviation is real; a genuine hashrate change still
clears the band promptly (verified live: a 4× change reacts within ~1 share). Set
significance_gate = false to retarget purely on the hysteresis band (tighter
tracking, more churn). gate_confidence (default 0.90) sets the band — higher =
stricter = fewer retargets.
Idle decay. Share-driven retargeting has a blind spot: a worker whose
difficulty ended up too high (snapshot from a beefier predecessor device
under the same worker name, thermal throttling, a misdetected class) stops
producing shares — and without shares there is nothing to trigger the
downward retarget. The idle decay closes that loop on a timer: after
idle_grace_factor × target_seconds_per_share of silence (default
20 × 15 s = 5 min — the chance a correctly-tuned worker is randomly silent
that long is e⁻²⁰), the difficulty steps down by max_step (÷4), then
again every idle_step_factor × target until shares flow again or the
class min_diff floor is reached. Three guards make this safe against the
classic crush bug (M45-goPool#32): the idle clock starts with the session
— never from a persisted timestamp, so a reconnecting miner is never
punished for the downtime; steps are gradual, never straight to the floor;
and the floor is a plain clamp, no formula artifacts. Workers pinned via
d= are exempt — an explicit request outranks the automation.
Miner difficulty requests are honored, clamped to the hardware class. Both standard V1 mechanisms work out of the box, no config needed:
mining.suggest_difficulty(d)— taken as the starting point: the pool setsdimmediately, VarDiff stays active and corrects later if the observed share rate doesn’t fit (the spec defines suggest as a hint, not a contract). Works before or aftermining.authorize.d=<x>in the authorize password field (e.g. passwordx,d=8192) — pins the difficulty for the session: VarDiff retargeting is disabled, the miner said explicitly what it wants. Useful for devices whose worker name field has a character whitelist (Avalon Q) and for proxy setups.
Both are clamped to the class-tuned [min_diff, max_diff] band — a
NerdMiner can’t request 1M (it would starve), an S21 can’t request 0.001
(it would flood the pool). Garbage values (0, negative, non-numeric) are
acknowledged but ignored; a hint must never break the session. d= beats
suggest; both beat the persisted snapshot from a previous connection.
Stratum V2 uses the same classes (since v1.37.1). A direct Sv2 channel
detects the miner class from the SetupConnection vendor/firmware/hardware
strings and the worker name, exactly like the V1 authorize path. A recognised
class starts at the channel’s nominal_hash_rate hint, converted with the
class share interval and clamped to the class band (a NerdMiner-class CPU
miner at ~290 kH/s opens near 0.006); without a hint it starts where V1
starts that class. Unrecognised miners keep a start floor of 4096, the guard
against a false low hashrate hint. The class decision is logged on INFO
(vardiff init mit class-Override (Sv2)).
NerdMiner class: a gentler way up (since v1.38).
The NerdMiner class (CPU and ESP32 miners, the WarpAxe dev kit) raises the
difficulty by at most ×2 per retarget; the window stays 8 shares (a wider
window would only loosen the significance gate after a fresh connection),
the way down (÷4, max_step) and the idle decay are unchanged, and so is
the NerdNOS class. At a share every 30 s, eight fast shares in a
row are a matter of an hour or two, and with the old window they could
triple a ~290 kH/s dev kit’s difficulty (about 13 instead of 30 shares an
hour until the way back). This is class tuning, not a [vardiff] key; V1
and Sv2 use the same values.
[ratelimit]
One limiter, both protocols: the Stratum V1 listener and the Sv2 listener share the same per-IP buckets, auto-ban list and IP gate. An Sv2 connection that trips the connect limit is dropped before the NOISE handshake starts.
[ratelimit]
enabled = true
connects_per_sec = 5.0 # per peer IP
connect_burst = 20
auths_per_sec = 1.0
auth_burst = 10
idle_evict_secs = 300 # after 5 min without traffic → bucket state is evicted
# Auto-ban: a public IP that keeps tripping the connect limit gets a full,
# escalating ban instead of just being throttled. Loopback + private LAN are
# never banned (local miners can reconnect in bursts).
autoban_enabled = true
autoban_strikes = 10 # connect-limit violations within the window …
autoban_window_secs = 60 # … that trigger a ban
autoban_base_secs = 300 # first ban 5 min; doubles per repeat …
autoban_max_secs = 3600 # … capped at 1 h
# Reject-ratio ban (Kern-Politur) — ban a worker that floods bad shares.
reject_ban_enabled = false # off by default
reject_ban_ratio = 0.5 # rejected/(accepted+rejected) over this …
reject_ban_min_samples = 50 # … once at least this many shares are seen → ban + drop
# Manual (permanent) ban list — operator-curated. Also editable live via the
# admin API / the Admin → Profile page. Applies to any IP except loopback.
manual_bans = ["203.0.113.7", "198.51.100.42"]
# IP-gate (Kern-Politur) — subnet-level access control, off by default.
ban_cidrs = ["203.0.113.0/24"] # deny whole ranges (CIDR or bare IP)
allow_only = false # true = strict whitelist (only allow_cidrs connect)
allow_cidrs = ["192.168.0.0/16"] # the whitelist (used only when allow_only = true)
Per-peer-IP token bucket. Triggered limits return mining.authorize with
result: false (no disconnect — the miner should not get stuck in a
reconnect loop). Auto-ban goes one step further for public IPs: after
autoban_strikes connect-limit violations within autoban_window_secs, the IP
is rejected outright (before TLS/handshake) for an escalating duration. Set
autoban_enabled = false to keep pure throttling. Loopback and private-LAN
addresses are exempt by design, so your own miners are never locked out.
The IP-gate adds subnet-level control on top of the single-IP manual_bans:
ban_cidrs refuses whole ranges, while allow_only = true flips to a strict
whitelist where only allow_cidrs (plus loopback) may connect — handy for a
private / invite-only pool. Both take CIDR ranges or bare IPs; malformed entries
are dropped (logged) at start; loopback always passes so you can’t lock yourself
out. All off by default.
Reject-ratio ban catches a different abuser: a worker that connects fine but
floods invalid shares. With reject_ban_enabled = true, once a session has sent
at least reject_ban_min_samples shares and its rejected fraction is ≥
reject_ban_ratio, that (public) IP is banned for the escalating auto-ban
duration and the connection dropped. Public-IP-only, same as the connect
auto-ban — your own LAN/loopback miners are never caught.
[notifier]
See Notifications for detailed sink setup guides.
[notifier]
webpush_subscriptions_path = "/var/lib/dvb-warppool/webpush.json"
[notifier.ntfy]
topic_url = "https://ntfy.sh/my-pool-topic-secret"
on_block_found = true
on_miner_disconnect = false
on_rpc_down = true
on_health_alert = true
[notifier.telegram] # supports on_block_found + on_health_alert only
bot_token_env = "TELEGRAM_BOT_TOKEN"
chat_id = "123456789"
on_block_found = true
on_health_alert = true
[notifier.discord] # supports on_block_found + on_health_alert only
webhook_url_env = "DISCORD_WEBHOOK_URL"
on_block_found = true
on_health_alert = true
[notifier.slack]
webhook_url_env = "SLACK_WEBHOOK_URL"
on_block_found = true
on_miner_disconnect = false
on_rpc_down = true
on_health_alert = true
[notifier.email]
smtp_url = "smtps://pool@mail.example.com:465"
from = "pool@example.com"
to = ["operator@example.com"]
password_env = "POOL_SMTP_PASSWORD"
on_block_found = true
on_miner_disconnect = false
on_rpc_down = true
on_health_alert = true
All sinks are optional. The daemon starts fine without a [notifier]
section at all. Sinks whose env vars are missing are skipped with a
notifier sink skipped warning.
[[webhooks]]
Generic outbound webhooks: the pool POSTs a JSON payload to each
configured URL whenever a matching pool event fires. This is the push
counterpart to the /api/events SSE pull stream — see
Add-ons & Sidecars for when to use which. Repeat the block for
multiple targets; omit it entirely and nothing is sent.
[[webhooks]]
url = "http://127.0.0.1:9000/ingest" # http:// or https:// ONLY
secret_env = "WARPPOOL_WEBHOOK_SECRET" # optional; enables HMAC signing
# events = [...] # omit → default low-frequency set
enabled = true # default true
# A second target, opting into high-frequency events:
[[webhooks]]
url = "https://example.com/hook"
events = ["block_found", "health_snapshot", "new_job", "shares_accepted"]
| Field | Required | Description |
|---|---|---|
url | yes | Target URL. Only http:// / https:// are accepted. |
secret_env | no | Name of the env var holding the HMAC secret (never inline). When set, every request carries X-WarpPool-Signature: sha256=<hex>. |
events | no | List of event type strings to deliver. Empty → default set (low-frequency only). |
enabled | no | Default true. Set false to keep the config but stop sending. |
Payload (Content-Type: application/json):
{ "event": "block_found", "ts": "2026-06-19T12:00:00+00:00", "data": { /* the full event object */ } }
Headers: X-WarpPool-Event: <type> always; X-WarpPool-Signature: sha256=<hmac>
only when secret_env is set. Verify the signature exactly like a GitHub webhook
— HMAC-SHA256(secret, raw_request_body), compared in constant time.
Default event set (when events is empty) — low-frequency only, so a webhook
never causes a POST storm out of the box:
block_found,health_snapshot,update_available,profile_switched
High-frequency events — new_job and shares_accepted fire constantly (a
new_job per template, an aggregated shares_accepted up to once a second). Only
list them if your consumer can handle the volume; otherwise prefer the default
set or the SSE stream. (heartbeat is also available but rarely useful for a
webhook.)
Delivery semantics: each POST has a ~10 s timeout and is retried with
exponential backoff (3 attempts total). If all attempts fail the event is logged
(webhook dropped after retries) and dropped — there is no unbounded buffer,
so a down consumer cannot back up the pool.
No SSRF protection — by design. Loopback / LAN targets are explicitly allowed because local sidecars are the main use case. The URL comes from this operator-trusted config, so only the URL scheme is validated. Do not point a webhook at an untrusted URL.
[retention]
[retention]
shares_raw_secs = 3600 # per-share rows before aggregation (1 h)
shares_agg5_secs = 604800 # 5-min share buckets (7 d)
miner_telemetry_raw_secs = 3600 # per-poll vendor probe results (1 h)
miner_telemetry_agg5_secs = 604800 # 5-min telemetry buckets (7 d)
workers_secs = 7776000 # idle worker rows — block-finders kept forever (90 d)
workers_inert_secs = 604800 # worker rows that never submitted a single share (7 d)
audit_log_secs = 7776000 # admin actions / logins / token mints (90 d)
api_tokens_retired_secs = 7776000 # grace AFTER revoke/expiry before the row is dropped (90 d)
push_subscriptions_stale_secs = 7776000 # grace after the LAST failed push attempt (90 d)
Every *_secs value here must be at least 60; the daemon refuses to start
otherwise.
All values are TTLs in seconds; eviction runs inside the daemon’s
aggregate loop (raw/agg tables every minute, audit/tokens/push hourly).
Defaults match the values that were hardcoded before the block existed —
omitting the section changes nothing. workers_secs bounds the one table a
client can grow by reconnecting under fresh worker names: idle worker rows are
evicted after the TTL, but any worker that ever found a block is kept forever.
workers_inert_secs is the short companion window for rows that never
submitted a single share (accepted or rejected): pool-directory probes connect
on every check with a freshly generated throwaway address, and those rows
should not sit out the full 90 days. A real miner stops being inert with its
first share; a misconfigured miner whose shares are all rejected stays visible
for debugging and only ages out via workers_secs. The dashboard’s
worker/wallet totals likewise count only workers with at least one accepted
share, so probe traffic never inflates the public numbers.
Two entries are conditional rather
than age-based: active API tokens never age out
(api_tokens_retired_secs counts from revocation or expiry), and push
subscriptions are only dropped when their most recent delivery attempt
failed (healthy but rarely-pushed subscriptions survive — on a solo pool
the next real push may be months away; 410 Gone endpoints are deleted
immediately by the push path itself).
[upnp]
[upnp]
enabled = false # default off — opt-in
renew_interval_secs = 3000 # 50 min, safely under the default 3600 s lease
[[upnp.forwards]]
port = 3333
protocol = "tcp" # "tcp" or "udp"
lease_seconds = 3600 # lease requested on every renew (60..=86400)
description = "dvb-WarpPool Stratum V1"
Phase 27: the setup wizard opens forwards once with a 1 h lease; if the
pool runs longer, the daemon has to renew them. With enabled = false
(default) no UPnP discovery task is started at all. Renewals are
idempotent on the router side; the first renew tick runs ~10 s after
boot so a crashed daemon recovers its forwards quickly.
[backup] — in-daemon auto-backup
[backup]
enabled = false # master switch (default off)
interval_secs = 86400 # seconds between backups (clamped to >=60); default daily
dir = "backups" # output directory (created if missing; relative to CWD)
keep = 7 # keep the newest N snapshots, prune older; 0 = keep all
When enabled = true the daemon writes a JSON ledger snapshot — the exact same
format as GET /api/admin/backup (workers, blocks, miners, vardiff, settings;
no secrets or raw shares) — to dir every interval_secs, named
warppool-backup-<timestamp>.json, and prunes to the newest keep. Off by
default: operators who already cron the API endpoint (or back up the SQLite file
externally) simply leave it disabled. Note this is unrelated to
[[node.backup]], which is Bitcoin-node failover.
[mining.electricity]
Note the path: electricity lives inside [mining], so the table headers are
[mining.electricity], [[mining.electricity.tou]] and
[mining.electricity.solar] — a bare [electricity] block is ignored.
[mining.electricity]
default_rate_eur_kwh = 0.30 # 0.0 = no tariff known, watt tracking only
[[mining.electricity.tou]] # optional time-of-use slots, first match wins
name = "HT"
rate_eur_kwh = 0.35
hours = "06:00-22:00" # wrapping slots like "22:00-06:00" work
weekdays = ["mon", "tue", "wed", "thu", "fri"] # empty/omitted = every day
[mining.electricity.solar]
enabled = false
kind = "home_assistant" # or "anker", "generic_http"
url = "http://127.0.0.1:8765/snapshot.json" # direct URL (sidecars); takes precedence over url_env
url_env = "WARPPOOL_HA_URL" # env var holding the provider base URL (cloud endpoints)
token_env = "WARPPOOL_HA_TOKEN" # auth token env var (HA long-lived token, ...)
pv_entity_id = "sensor.pv_power_total" # required for kind = "home_assistant"
consumption_entity_id = "sensor.house_power" # optional; omitted → compare against pool power
battery_discharge_entity_id = "sensor.battery_out" # optional; enables the battery source
battery_charge_entity_id = "sensor.battery_in" # optional
grid_entity_id = "sensor.grid_import" # optional; derived from house load if absent
feed_in_entity_id = "sensor.grid_export" # optional
battery_soc_entity_id = "sensor.battery_soc" # optional, display only
poll_interval_secs = 60
excess_rate_eur_kwh = 0.0 # rate while PV excess covers the pool
surplus_buffer_w = 200.0 # pv − consumption must exceed this margin (anti-flap)
consumption_includes_pool = true # default; the house meter already contains the miners
stale_after_secs = 300 # snapshot older than this → fall back to TOU/default
consumption_includes_pool says what the consumption feed measures. A
house smart meter (Anker, most Home Assistant setups) reports the whole
house load, miners included, so the surplus is simply what PV delivers
beyond that load: pv_w − consumption_w ≥ surplus_buffer_w. Set it to
false only when the consumption sensor is a sub-meter that does not see
the miners; the pool then adds its own power to the threshold
(≥ pool_power_w + surplus_buffer_w). Before v1.32.0 the pool always
used the latter rule, which counted the miners twice on a house meter and
kept the surplus flag off until PV covered them a second time.
Drives the cost figures on the energy card: the effective rate per
5-minute bucket is solar-excess (if active), else the first matching
TOU slot, else default_rate_eur_kwh. Solar provider credentials for
the daemon-managed Anker bridge live in secrets.toml
(anker_email/anker_password/anker_country), not here.
Announcement banner (runtime, not config.toml)
Since v1.21.0. The scrolling announcement strip under the public pool header (“maintenance tonight 22:00”, “welcome, new miners”) is runtime state, not a config key: it is set from Admin → Banner or over the API, takes effect without a restart, and expires by itself.
| Endpoint | Auth | Does |
|---|---|---|
GET /api/admin/banner | admin | Returns the stored text, expires_at and a computed active flag. |
POST /api/admin/banner | admin | Starts an announcement. Body: {"text": "...", "duration_secs": 3600}. |
DELETE /api/admin/banner | admin | Ends it early. Clearing an already-empty banner is a no-op, not an error. |
POST validation — all violations return 400:
| Rule | Limit |
|---|---|
text non-empty after trimming | required |
text length | ≤ 200 characters |
text control characters | rejected (they would break the marquee) |
duration_secs | 60 s … 90 days |
The text is stored and rendered as plain text (the UI writes it via
textContent, never as HTML).
Persistence is two rows in the pool_settings key-value table:
| Key | Value |
|---|---|
banner_text | the announcement text (empty ⇒ no banner) |
banner_expires_at | RFC3339 timestamp, computed as now + duration_secs |
Because the expiry is stored rather than scheduled, the banner self-clears: a
missing, unparseable or already-passed banner_expires_at simply yields no
banner.
Public read path: while active, GET /api/overview carries a
banner object {"text": "...", "expires_at": "<rfc3339>"}. The field is
absent when there is no active banner — consumers must treat it as
optional, not as an empty string. The UI additionally hides the strip locally
at expires_at, so it disappears on time instead of at the next overview poll.
secrets.toml (chmod 600)
rpc_user = "warppool" # optional, alternative to cookie
rpc_pass = "..." # optional, ditto
admin_username = "admin" # default "admin"
admin_password_hash = "$argon2id$..." # via `dvb-warppool-cli hash-password`
jwt_secret = "..." # min 32 bytes recommended; empty → admin auth disabled
sv2_authority_priv_key_hex = "..." # via `dvb-warppool-cli gen-sv2-key`
An empty admin_password_hash or empty jwt_secret makes every
/api/admin/* endpoint return 503 “auth disabled”. Useful for a
read-only setup without an admin UI.
Credentials written by the admin UI
These are not meant to be hand-edited — the admin pages write them here, and
none of them is ever returned by the API. They exist so the operator does not
have to plant env vars by hand: on start the daemon hydrates the env vars that
the [notifier] sink configs reference (bot_token_env and friends) from
these values.
notifier_telegram_bot_token = "..." # via Admin → Notifications
notifier_discord_webhook_url = "..."
notifier_slack_webhook_url = "..."
notifier_smtp_password = "..."
anker_email = "you@example.com" # via Admin → Profile → Photovoltaik
anker_password = "..." # GET /api/admin/solar only reports anker_creds_set: bool
anker_country = "DE" # ISO-3166, default "DE"
With solar.kind = "anker" plus email and password set, the daemon starts the
Anker bridge itself as a child process — no separate launchd/systemd service
needed.
Phase 21: VAPID Web Push Secrets
vapid_public_key = "B..." # via `dvb-warppool-cli gen-vapid-keys`
vapid_private_key = "..." # same command
vapid_contact = "mailto:operator@example.org" # optional, default mailto:operator@localhost
The public key is served via /api/push/vapid-public-key (public,
no-auth — the UI needs it for PushManager.subscribe). The private key
stays daemon-side only. If either value is empty, web push is disabled —
the daemon log reports “web-push disabled (no vapid keys)”.
Environment Variables
Bitcoin Health Check
| Var | Default | Description |
|---|---|---|
WARPPOOL_HEALTH_CHECK_INTERVAL_SECONDS | 60 | Interval of the daemon’s periodic Bitcoin health check. 0 = off. |
When the check is enabled, the daemon emits HealthSnapshot SSE events
and fires RpcDown/RpcRecovered notifications on transitions.
Auto-Update
| Var | Default | Description |
|---|---|---|
WARPPOOL_AUTOUPDATE_REPO | (unset) | e.g. dvb-projekt/dvb-WarpPool. When set, /api/admin/update is active. |
WARPPOOL_AUTOUPDATE_INTERVAL_HOURS | 24 | How often to poll the forge (git.warppool.org) for new releases. 0 = off. |
WARPPOOL_COSIGN_BIN | (unset) | Path to the cosign binary for verify-blob. Required when cosign_verify=true in the update request. |
Stratum / Notifier Tuning
| Var | Default | Description |
|---|---|---|
WARPPOOL_DISCONNECT_DEBOUNCE_SECS | 30 | Per-worker debounce for MinerDisconnect notifications. |
Solar/PV Provider (Phase 20.5)
The electricity tariff switches to excess_rate_eur_kwh (typically 0.0)
when the PV array produces more than house + pool consume together.
Supported kind values: anker (daemon-managed Anker Solix bridge —
enter your Anker credentials under Admin → Photovoltaics, the pool runs
the bridge itself), home_assistant (REST API, shown below), and
generic_http (any endpoint serving the snapshot JSON).
[mining.electricity.solar]
enabled = true
kind = "home_assistant"
url_env = "WARPPOOL_HA_URL" # e.g. http://homeassistant.local:8123
token_env = "WARPPOOL_HA_TOKEN" # Long-Lived Access Token
pv_entity_id = "sensor.pv_power_total"
consumption_entity_id = "sensor.grid_load_total" # optional
poll_interval_secs = 60 # 60s is enough to track PV trends
surplus_buffer_w = 200 # safety buffer
consumption_includes_pool = true # house meter sees the miners (default)
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,
plus the battery fields below when the provider supplies them.
Where the kilowatt-hours came from
The energy card breaks the pool’s consumption into solar, battery and grid. Battery means solar that was stored earlier in the day and consumed later — without that third bucket every evening kilowatt-hour served by a home battery would show up as grid power. That is what the old PV-versus-load check concluded (production at zero, load above it), not where the power actually came from: at the meter such an evening shows no import at all.
Whose kilowatt-hours are they? Every minute the daemon asks what serves the pool’s load specifically — not what the house runs on. The miners are the last consumer switched on, so their kilowatt-hours are the marginal ones: what would stop flowing if you unplugged them. They are charged against the sources in the order those would fall away — grid import first, then battery discharge, then direct PV — and whatever the pool doesn’t reach stays with the rest of the house.
That matters most in the evening. House on 200 W grid and 600 W battery, 300 W of it miners: the pool is charged 200 W grid + 100 W battery. The house average would have said 75 % battery, but unplug the miners and the grid draw goes to zero — those watts were the pool’s. With the grid at zero the same pool is 100 % battery, which is the case this split exists for.
It stays proportional rather than all-or-nothing: when the marginal load spans two sources, both get their share of the minute. Battery kWh cost the same as solar kWh (nothing) — only the grid share is priced, in the energy card and in the week/month/year view alike.
The split needs battery_discharge_w from the provider. Without it (no
battery, or entities not configured) the daemon falls back to the older
yes/no decision — does the PV surplus cover the pool? — and books each
minute wholly as solar or wholly as grid, leaving the battery bucket at
zero. A stale snapshot (older than stale_after_secs) triggers the same
fallback. The third bar only appears once a battery kilowatt-hour has
actually been counted, so installations without storage never see an
empty third of a chart.
For Home Assistant the five optional entities above are also editable in the setup wizard and under Admin → Profile → Photovoltaics (“Battery sensors”); the Anker bridge reports all of them on its own.
Sensor conventions. Every entity must report watts as a positive number, one direction per sensor. Many HA integrations instead expose a single signed
battery_power(e.g. positive while discharging, negative while charging — the sign convention varies by integration). Pointing the discharge entity at such a sensor breaks the split whenever the battery is charging, because a negative value is not a discharge: split it into two template sensors first, one clamped to each direction.
The generic_http snapshot schema — what scripts/anker-bridge.py serves
and what any custom sidecar may serve:
{
"pv_w": 2800.0,
"consumption_w": 2536.0,
"battery_discharge_w": 0.0,
"battery_charge_w": 1745.0,
"grid_w": 1479.0,
"feed_in_w": 0.0,
"battery_soc_pct": 30.0,
"at": "2026-09-11T10:11:44Z"
}
Only pv_w is mandatory; every other key may be omitted. Directly used PV
is derived as pv_w − battery_charge_w − feed_in_w, so a sidecar that
reports charging keeps the solar share honest while the battery fills. As
a guard against a field going missing mid-flight, the flows are checked
against consumption_w: if they disagree with the metered house load by
more than 25 %, the minute takes the yes/no fallback instead of booking a
wrong origin.
[mining.tune] — dvb-WarpTune badge
dvb-WarpTune is the auto-clock sidecar
for AxeOS miners (Bitaxe, NerdAxe Gamma, NerdAxe Gaia, NerdQaxe, NerdOctaxe, BitForge Nano). It
regulates frequency and core voltage against a temperature target with
guardrails, and reads the pool’s /api/workers (reject reasons) and
/api/energy (tariff, PV surplus) to do it. The pool polls the sidecar’s
snapshot and shows a badge per device in the Miners tab. Since v1.36.0 one
thing flows back: the calibration buttons on the devices page (start,
pause, resume, stop, apply, discard; since v1.38 also Quick check now),
for the logged-in admin only, over a shared key.
[mining.tune]
enabled = true
url = "http://192.168.178.20:8766" # sidecar.bind in warptune.toml; 0.0.0.0 there when the pool runs on another box
# token_env = "WARPTUNE_TOKEN" # env var with the shared key — or enter the key under Admin → Profile → coupling (stored in secrets.toml)
poll_interval_secs = 30 # the sidecar decides every 30 s
stale_after_secs = 120 # older copies show as stale
GET /api/tune returns the pool’s copy with its age; /api/miners carries a
tune object per device (mode, live, status, operating point, last
decision, the calibration state and its job — per stage the voltage found
and take_mv, what Apply takes over: found plus the sidecar’s
calib_margin_mv reserve, 20 mV by default, never above the reference;
with the clock search (v1.37, calib_clock on the miner) the clock stages
eco_clock / max_clock with every candidate’s verdict and window
maxima, calib_found_clocks, calib_clock, the firmware’s fan_target_c
and calib_fan_target_c the result was measured under, and a stage’s
fan_advice with a suggested_target_c the admin can take over with
fan_target; per voltage stage floor_kind — bracketed,
hard_minimum, open or firmware_minimum with clamped_mv — and
calib_floor_kind on the badge; since v1.38 calib_yield_picks, the
voltage the yield rule preferred over the lowest good one per stage and
why, calib_quick / calib_auto for a quick job and the automatic
re-check, calib_applied_quick when the calibration in force came from a
quick job, calib_counters when the firmware has the chip error rate a quick job
needs (1-minute rate or per-chip readings; hashrateMonitor alone is
not enough), and chain_reinit, the last chain re-init with its
time, outcome and reason). Devices are matched by host, resolved IP or
the device hostname.
Since v1.29.0 the block is also editable under Admin → Profile → dvb-WarpTune
coupling (GET/POST /api/admin/tune, audit action tune.set); changes
apply live, no restart. The coupling is pool-wide, one sidecar per operator;
what each miner does is the sidecar’s own config.
POST /api/admin/tune/calib with {"host": "<sidecar host key>", "action": "start" | "quick" | "stop" | "pause" | "resume" | "apply" | "discard" | "fan_target", "target_c": <°C, fan_target only>} forwards one calibration command to the
sidecar (POST {url}/calib/{host}/{action}, for fan_target
POST {url}/calib/{host}/fan_target/{target_c}, with
the shared key as bearer token: the variable token_env names where it is
set, else the key entered under Admin → Profile → dvb-WarpTune coupling,
which lands in secrets.toml as tune_token — the way for Umbrel, whose
container has no custom environment). The host must be one the last snapshot
listed; the sidecar checks the command against its own state (a second
start, a stop with nothing running, an apply without a result, and a miner
whose entry is older than 90 s because the device is not answering come
back as 409) and picks it up on the miner’s next tick; a command the loop
has not picked up within 60 s is dropped rather than carried out later.
Every attempt is audited as tune.calib.<action>. Without a key on the pool, or with a sidecar that has
no control_token_env, the buttons do nothing and the pool keeps displaying
(the sidecar refuses every POST; GET /snapshot is not affected).
For visitors of a public pool the direction is reversed: their sidecar
pushes its snapshot to POST /api/tune/push with a token created in the
owner area of their wallet page (POST /api/users/<address>/tune-token,
bound to that address, revocable). The pool keeps the latest snapshot per
wallet in memory for an hour, marks it stale after two minutes, accepts at
most one push per ten seconds and 64 miners per snapshot, and refuses a
snapshot whose miners mine to another wallet. The badge appears on that
wallet page; visitors see it only if the owner turns Show badge to
visitors on. Nothing in [mining.tune] is needed for this.
On Umbrel the pool’s own API sits behind the app login. The app therefore
maps a second port, 18335 by default (APP_DVB_WARPPOOL_API_PORT), to the
daemon’s read-only listener (api_readonly_listen, container port
18339) so a sidecar on the LAN can read /api/workers and /api/energy
without the login. That listener serves the same anonymous GETs as the
public pool plus the token-guarded POST /api/tune/push, and nothing
else — no /api/auth, no /api/admin, and the first-run setup wizard
never listens on it. (Releases 1.28.0–1.30.0 mapped
that port straight onto the main API port instead, which also exposed the
setup wizard and the first-run admin bootstrap to the LAN until the owner
had completed them; update if you installed one of those versions.)
Vendor Probes (Phase 22)
| Var | Default | Description |
|---|---|---|
WARPPOOL_AUTO_PROBE_DISCOVERED | (off) | 1/true/yes: in addition to DB miners, devices found via mDNS are polled every 30s. Telemetry values land in an in-memory cache (no DB write) and are exposed in /metrics with label="discovered". |
Health Anomaly Check (Phase 20.3b)
| Var | Default | Description |
|---|---|---|
WARPPOOL_ANOMALY_CHECK_INTERVAL_SECS | 300 | Interval of the periodic anomaly detection. 0 = off. |
WARPPOOL_ANOMALY_DEBOUNCE_SECS | 1800 | Per-(miner, alert_kind) debounce. While a symptom persists, the notifier does not fire on every tick — only again after this interval. |
Critical alerts (FanStuck, StaleData) are sent to every sink with
on_health_alert = true (default). Warnings (ThermalThrottling,
VoltageDrop, HashrateDrop) are UI-only via /api/miners/:id/alerts.
Notifier Secrets
The variable names are FREELY CHOSEN — you set the name in config.toml,
and the daemon reads that env var.
Typical names:
TELEGRAM_BOT_TOKEN→notifier.telegram.bot_token_envDISCORD_WEBHOOK_URL→notifier.discord.webhook_url_envSLACK_WEBHOOK_URL→notifier.slack.webhook_url_envPOOL_SMTP_PASSWORD→notifier.email.password_env
Test/Debug
| Var | Default | Description |
|---|---|---|
RUST_LOG | info | Standard tracing-subscriber filter. Use warppool=debug,info for selective debug. |
WARPPOOL_E2E_REGTEST_* | — | Set by scripts/regtest-up.sh. See Testing. |
Deployment / container overrides
Set by the Docker and Umbrel packaging; rarely touched on a bare-metal install.
Every WARPPOOL_* flag below is truthy for exactly 1, true or yes.
| Var | Default | Description |
|---|---|---|
DVB_WARPPOOL_STRATUM_TLS_LISTEN | (unset) | Overrides stratum.stratum_tls_listen. The --no-tls CLI flag still wins. |
DVB_WARPPOOL_SV2_LISTEN | (unset) | Overrides stratum.sv2_listen. |
DVB_WARPPOOL_DEPLOY | (unset) | umbrel / docker — tells the daemon it runs in a container, so it stops looking for a host bitcoin.conf and reports Bitcoin Core as external. |
DVB_WARPPOOL_BITCOIN_DATA_MOUNT | /bitcoin | Where the Bitcoin datadir is mounted; used to resolve the IPC socket. |
DVB_WARPPOOL_TEMPLATE_SOURCE | (unset) | Overrides node.template_source: gbt, ipc or auto (probe the IPC socket at start, fall back to GBT). |
APP_BITCOIN_IPC_SOCKET_RELATIVE_PATH | (unset) | Set by the Umbrel orchestrator: the IPC socket path relative to the datadir mount; used by auto and as the Admin UI prefill. |
APP_BITCOIN_IPC_ENABLED | (unset) | Set by the Umbrel orchestrator: whether the Bitcoin app has its IPC Mining Interface toggle on; shown as a hint in the Admin UI. |
WARPPOOL_DATA_DIR | (platform default) | Overrides the data directory used for the install marker. |
WARPPOOL_DISABLE_MDNS | (off) | Turns mDNS miner discovery off entirely. |
DVB_WARPPOOL_ANKER_BRIDGE | /opt/dvb-warppool/anker-bridge/anker-bridge.py | Path to the Anker bridge script. |
DVB_WARPPOOL_ANKER_REPO | /opt/dvb-warppool/anker-solix-api | Checkout of the anker-solix-api library. |
DVB_WARPPOOL_ANKER_PYTHON | (auto) | Python interpreter for the bridge; without it, venv detection with a python3 fallback. |
CLI Override
dvb-warppool-daemon --help lists the CLI flags that can override
individual config values. Common ones:
dvb-warppool-daemon \
--config /etc/dvb-warppool/config.toml \
--secrets /etc/dvb-warppool/secrets.toml \
--data-dir /var/lib/dvb-warppool \ # DB lives at <data-dir>/warppool.db
--ui-dir /usr/share/dvb-warppool/ui \
--no-zmq # debug-only: force poll-only mode
Reload Behavior
There is currently no hot reload for config.toml. Restart the
daemon after every change:
systemctl restart dvb-warppool
The only hot-switchable items are:
- Profile (
POST /api/admin/profile) — resizes the connection cap, the VarDiff range, the miner-poll interval, the retention TTLs and the rate limits live (see thelive/restart/guidetable above) - Announcement banner (
POST/DELETE /api/admin/banner) — effective immediately, and self-expiring - Manual IP bans (
POST /api/admin/ratelimit/ban) — effective immediately - Suppressed health warnings (Admin → Health) — DB-backed after first start
- Notifier credentials (
POST /api/admin/notifier/config) — written tosecrets.toml - Admin tokens (
POST /api/admin/tokens) — effective immediately - 2FA (
POST /api/auth/2fa/enable) — effective immediately
See Also
- Getting Started — minimum config for first boot
- Notifications — sink-specific examples
- Setup Health Checks — wizard workflow
- Troubleshooting — common failure modes