Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Configuration Reference

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

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

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

config.toml

[branding]

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

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

The announcement banner is not configured here. The marquee strip under the header is runtime state in the database, not a config.toml key — see Announcement banner.

[legal]
terms_url = "https://example.com/terms"
privacy_url = "https://example.com/privacy"
privacy_last_updated = "2026-02-14"

Optional. All fields default to empty. When empty (the solo default), the UI shows no legal notice. When a public / commercial operator sets terms_url and/or privacy_url, the dashboard footer renders a “By connecting mining device(s) or software to this pool, you acknowledge…” notice linking to those URLs (with the optional privacy_last_updated stamp). The legal texts themselves are the operator’s responsibility. URLs are restricted to http/https.

[privacy]

[privacy]
wallet_directory_public      = true   # false → Workers directory is operator-only
leaderboard_addresses_public = true   # false → mask addresses in the best-shares leaderboard

Optional. Both default to true = the historical open behaviour, so existing deployments are unchanged on upgrade. They control how much a public pool exposes, matching the convention of established solo pools:

  • wallet_directory_public = false hides the browsable Workers/wallet directory (/api/wallets and its nav link) from anonymous visitors — like solo.ckpool.org, which has no public directory at all. Each per-address page /users/<address> stays reachable by direct link, and the authenticated operator still sees the full directory. Note: for the operator to keep seeing it, admin auth must be enabled (an unauthenticated request — including the operator’s own browser without a session — is treated as public).
  • leaderboard_addresses_public = false masks the Bitcoin address in the public best-shares leaderboard server-side (e.g. bc1qsx…dvng, rig suffix dropped) — like AtlasPool. The authenticated operator still sees full addresses.

Neither affects payouts, block construction, or the per-miner opt-in privacy flag (an address owner can already hide themselves via the signature-gated Owner Area on their /users/<address> page); the two mechanisms are orthogonal.

[logging]

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

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

[mining]

[mining]
payout_mode = "single"               # "single" (default) | "per_worker"
payout_address = "bc1q..."           # REQUIRED in "single" — block-reward recipient
pool_fee_percent = 0.0                # Default = no pool fee (solo)
pool_fee_address = ""                 # Required when fee_percent > 0
pooltag_prefix = "dvbprojekt-WarpPool"  # max 64 bytes, written to coinbase scriptSig
operator_donation_address = ""
operator_donation_percent = 0.0
# Address-allowlist (Kern-Politur): payout addresses allowed to mine. Empty
# (default) = open to anyone. For a private / invite-only pool.
address_allowlist = ["bc1qalice...", "bc1qbob..."]

payout_mode — where the block reward goes:

  • "single" (default): the full reward goes to the single pool-wide payout_address. This is the ordinary solo / friends setup and is 100% backward-compatible.
  • "per_worker": each miner mines to their own address, taken from the Stratum login (<address>.<worker>). The pool never holds funds — a public, non-custodial 0%-fee solo pool. In this mode payout_address is ignored and may be empty, and pool_fee_percent / operator_donation_percent must be 0 (the full reward goes to the finder). A miner whose login is not a valid address for this network is rejected. See docs/design/per-worker-coinbase.md.

For multi-user-solo (hosting-service setup) in "single" mode: set pool_fee_percent = 1.0 and pool_fee_address. 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_release in 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) — getblocktemplate over RPC, refreshed on ZMQ block events plus the job_refresh_secs poll. Works with every Bitcoin Core release.
  • "ipc" — the Cap’n-Proto mining interface of a multiprocess Bitcoin Core ≥ 31 (bitcoin-node -ipcbind=unix), which pushes a new template via waitNext instead of being polled. 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 the ipc_template_stale health 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.
  • submitblock goes to ALL nodes in parallel — the block moment is the one instant where a dead node can cost real money; any Accepted wins.
  • ZMQ subscribes to every node that has a zmq_hashblock_addr; the job loop deduplicates block events by hash, so whichever node reports first triggers the refresh.
  • Notifications: a node switch fires node-failover (delivered to sinks that subscribed to on_rpc_down); rpc-down itself now means “no node reachable at all” — a pool running happily on a backup is degraded, not down, and the health snapshot carries a warning instead.

Verified live: primary kill → failover < 1 s with the job stream uninterrupted; primary restart → automatic return ≤ 60 s.

[server]

[server]
pool_listen = ":3333"
status_listen = ":18334"
status_tls_listen = ":18443"          # optional
# 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:

BadgeMeaningValues
liveapplies immediately, no restartconnection cap, VarDiff range, miner-poll interval, retention TTLs, Stratum-auth & API rate limits
restarttakes effect on the next daemon startdb_pool_size (SQLite pools can’t resize live)
guideadvisory only — a switch does not change itstratum_threads / share_validator_threads (fixed at runtime), ram_target_mb (a hint), ws_update_interval (a UI-poll guide; the backend pushes via SSE)

The same honesty rule as VarDiff applies to retention and the rate limits: hand-set config.toml values for [retention] / [ratelimit] stand as long as you never switch profiles at runtime. Retention maps the profile’s four tiers (raw_shares / agg_5min / telemetry_raw, with agg_5min also driving the 5-minute telemetry tier) onto the live eviction; the operator-only fields (audit_log_secs, api_tokens_retired_secs, push_subscriptions_stale_secs) are never touched by a switch. agg_1h has no TTL eviction — those rows are rolled into daily_aggregates.

Profile defaults at a glance

The four profiles each bundle a set of presets for a pool size. This is the same table shown on the Hardware page (where the active profile’s column is highlighted). The Scope column is the live / restart / guide behaviour from the badge table above. The headers below use the profiles’ English display names; the matching config kind values are klein (Small), mittel (Medium), gross (Large), and enterprise.

SettingScopeSmallMediumLargeEnterprise
Recommended miners—≤5≤20≤100>100
Deployment—SoloSolo + friendsCommunity¹Service¹
Connection caplive16642564,096
RAM targetguide128 MB512 MB2 GB8 GB
Stratum threadsguide12all coresall cores
DB poolrestart481632
UI refreshguide5 s2 s1 s1 s
Miner pollinglive30 s15 s5 s1 s
VarDiff initiallive4,09632,7681,048,57616,777,216
VarDiff minlive1,0244,09665,536524,288
VarDiff maxlive262,1444,194,30467,108,8641,073,741,824
Retention — raw shareslive1 d7 d30 d90 d
Retention — 5-min agglive7 d30 d90 d1 y
Retention — 1-h aggguide90 d1 y5 y10 y
Retention — miner telemetrylive1 d7 d30 d90 d
Stratum auth / minlive601206006,000
API / minlive1206003,00060,000

¹ Community and Service assume commercial extensions outside the OSS scope — see Scaling Tiers.

What each row means:

  • Connection cap — maximum simultaneous miner connections; beyond it new ones are refused.
  • RAM target — a rough memory guideline for the size class, not a hard cap (the pool does not pre-allocate it).
  • Stratum threads — Tokio worker threads for the Stratum listener (all cores = num_cpus); fixed when the runtime starts.
  • DB pool — SQLite connection-pool size (more = more concurrent DB access); fixed at startup, hence restart.
  • UI refresh — a guideline for how often the dashboard updates; the backend pushes live events over SSE anyway, so it is advisory only.
  • Miner polling — how often the pool polls each miner’s telemetry (temperature / watts / volts).
  • VarDiff initial / min / max — the per-profile defaults the running VarDiff starts from and stays within. Sub-1 difficulty stays possible for tiny devices via suggest_difficulty, regardless of min.
  • Retention — how long each data class is kept before automatic eviction; larger profiles keep more history.
  • Stratum auth / API per min — per-IP rate limits; loopback and the LAN are exempt (see [ratelimit]).

[vardiff]

[vardiff]
enabled = true
target_seconds_per_share = 15.0
window = 8
min_diff = 1.0
max_diff = 65536.0
retarget_after_n_shares = 4
hysteresis = 0.20          # 20% — a new diff that deviates > 20% from the old one triggers a retarget
max_step = 4.0             # max 4× per retarget
initial_diff = 1.0
idle_decay_enabled = true  # time-driven decay for workers that went quiet
idle_grace_factor = 20.0   # × target_seconds_per_share of silence before the first decay step
idle_step_factor = 8.0     # × target between further steps while still quiet
significance_gate = true   # only retarget when the deviation is statistically significant (default on since v1.11.0)
gate_confidence = 0.90     # confidence band of the significance gate

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

Significance gate. significance_gate (on by default) makes VarDiff retarget only when the EMA share-rate deviation is statistically significant for the number of shares seen — formally when |ema/target − 1| · √window > z_crit(gate_confidence). A worker producing few shares per window (NerdMiner, NerdNOS and other sub-MH/s / ESP-class miners) has a Poisson-noisy observed rate (≈ 1/√N), and a fixed hysteresis band reads that noise as a real hashrate change — flapping the difficulty and flooding the miner with mining.set_difficulty (the visible ESP32 display flicker). The gate holds the difficulty until the deviation is real; a genuine hashrate change still clears the band promptly (verified live: a 4× change reacts within ~1 share). Set significance_gate = false to retarget purely on the hysteresis band (tighter tracking, more churn). gate_confidence (default 0.90) sets the band — higher = stricter = fewer retargets.

Idle decay. Share-driven retargeting has a blind spot: a worker whose difficulty ended up too high (snapshot from a beefier predecessor device under the same worker name, thermal throttling, a misdetected class) stops producing shares — and without shares there is nothing to trigger the downward retarget. The idle decay closes that loop on a timer: after idle_grace_factor × target_seconds_per_share of silence (default 20 × 15 s = 5 min — the chance a correctly-tuned worker is randomly silent that long is e⁻²⁰), the difficulty steps down by max_step (÷4), then again every idle_step_factor × target until shares flow again or the class min_diff floor is reached. Three guards make this safe against the classic crush bug (M45-goPool#32): the idle clock starts with the session — never from a persisted timestamp, so a reconnecting miner is never punished for the downtime; steps are gradual, never straight to the floor; and the floor is a plain clamp, no formula artifacts. Workers pinned via d= are exempt — an explicit request outranks the automation.

Miner difficulty requests are honored, clamped to the hardware class. Both standard V1 mechanisms work out of the box, no config needed:

  • mining.suggest_difficulty(d) — taken as the starting point: the pool sets d immediately, VarDiff stays active and corrects later if the observed share rate doesn’t fit (the spec defines suggest as a hint, not a contract). Works before or after mining.authorize.
  • d=<x> in the authorize password field (e.g. password x,d=8192) — pins the difficulty for the session: VarDiff retargeting is disabled, the miner said explicitly what it wants. Useful for devices whose worker name field has a character whitelist (Avalon Q) and for proxy setups.

Both are clamped to the class-tuned [min_diff, max_diff] band — a NerdMiner can’t request 1M (it would starve), an S21 can’t request 0.001 (it would flood the pool). Garbage values (0, negative, non-numeric) are acknowledged but ignored; a hint must never break the session. d= beats suggest; both beat the persisted snapshot from a previous connection.

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"]
FieldRequiredDescription
urlyesTarget URL. Only http:// / https:// are accepted.
secret_envnoName of the env var holding the HMAC secret (never inline). When set, every request carries X-WarpPool-Signature: sha256=<hex>.
eventsnoList of event type strings to deliver. Empty → default set (low-frequency only).
enablednoDefault true. Set false to keep the config but stop sending.

Payload (Content-Type: application/json):

{ "event": "block_found", "ts": "2026-06-19T12:00:00+00:00", "data": { /* the full event object */ } }

Headers: X-WarpPool-Event: <type> always; X-WarpPool-Signature: sha256=<hmac> only when secret_env is set. Verify the signature exactly like a GitHub webhook — HMAC-SHA256(secret, raw_request_body), compared in constant time.

Default event set (when events is empty) — low-frequency only, so a webhook never causes a POST storm out of the box:

  • block_found, health_snapshot, update_available, profile_switched

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

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

POST validation — all violations return 400:

RuleLimit
text non-empty after trimmingrequired
text length≤ 200 characters
text control charactersrejected (they would break the marquee)
duration_secs60 s … 90 days

The text is stored and rendered as plain text (the UI writes it via textContent, never as HTML).

Persistence is two rows in the pool_settings key-value table:

KeyValue
banner_textthe announcement text (empty ⇒ no banner)
banner_expires_atRFC3339 timestamp, computed as now + duration_secs

Because the expiry is stored rather than scheduled, the banner self-clears: a missing, unparseable or already-passed banner_expires_at simply yields no banner.

Public read path: while active, GET /api/overview carries a banner object {"text": "...", "expires_at": "<rfc3339>"}. The field is absent when there is no active banner — consumers must treat it as optional, not as an empty string. The UI additionally hides the strip locally at expires_at, so it disappears on time instead of at the next overview poll.

secrets.toml (chmod 600)

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

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

Credentials written by the admin UI

These are not meant to be hand-edited — the admin pages write them here, and none of them is ever returned by the API. They exist so the operator does not have to plant env vars by hand: on start the daemon hydrates the env vars that the [notifier] sink configs reference (bot_token_env and friends) from these values.

notifier_telegram_bot_token = "..."   # via Admin → Notifications
notifier_discord_webhook_url = "..."
notifier_slack_webhook_url = "..."
notifier_smtp_password = "..."

anker_email = "you@example.com"       # via Admin → Profile → Photovoltaik
anker_password = "..."                # GET /api/admin/solar only reports anker_creds_set: bool
anker_country = "DE"                  # ISO-3166, default "DE"

With solar.kind = "anker" plus email and password set, the daemon starts the Anker bridge itself as a child process — no separate launchd/systemd service needed.

Phase 21: VAPID Web Push Secrets

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

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

Environment Variables

Bitcoin Health Check

VarDefaultDescription
WARPPOOL_HEALTH_CHECK_INTERVAL_SECONDS60Interval of the daemon’s periodic Bitcoin health check. 0 = off.

When the check is enabled, the daemon emits HealthSnapshot SSE events and fires RpcDown/RpcRecovered notifications on transitions.

Auto-Update

VarDefaultDescription
WARPPOOL_AUTOUPDATE_REPO(unset)e.g. dvb-projekt/dvb-WarpPool. When set, /api/admin/update is active.
WARPPOOL_AUTOUPDATE_INTERVAL_HOURS24How often to poll the forge (git.warppool.org) for new releases. 0 = off.
WARPPOOL_COSIGN_BIN(unset)Path to the cosign binary for verify-blob. Required when cosign_verify=true in the update request.

Stratum / Notifier Tuning

VarDefaultDescription
WARPPOOL_DISCONNECT_DEBOUNCE_SECS30Per-worker debounce for MinerDisconnect notifications.

Solar/PV Provider (Phase 20.5)

The electricity tariff switches to excess_rate_eur_kwh (typically 0.0) when the PV array produces more than house + pool consume together. Supported kind values: anker (daemon-managed Anker Solix bridge — enter your Anker credentials under Admin → 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)

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_TOKEN → notifier.telegram.bot_token_env
  • DISCORD_WEBHOOK_URL → notifier.discord.webhook_url_env
  • SLACK_WEBHOOK_URL → notifier.slack.webhook_url_env
  • POOL_SMTP_PASSWORD → notifier.email.password_env

Test/Debug

VarDefaultDescription
RUST_LOGinfoStandard tracing-subscriber filter. Use warppool=debug,info for selective debug.
WARPPOOL_E2E_REGTEST_*—Set by scripts/regtest-up.sh. See Testing.

Deployment / container overrides

Set by the Docker and Umbrel packaging; rarely touched on a bare-metal install. Every WARPPOOL_* flag below is truthy for exactly 1, true or yes.

VarDefaultDescription
DVB_WARPPOOL_STRATUM_TLS_LISTEN(unset)Overrides stratum.stratum_tls_listen. The --no-tls CLI flag still wins.
DVB_WARPPOOL_SV2_LISTEN(unset)Overrides stratum.sv2_listen.
DVB_WARPPOOL_DEPLOY(unset)umbrel / docker — tells the daemon it runs in a container, so it stops looking for a host bitcoin.conf and reports Bitcoin Core as external.
DVB_WARPPOOL_BITCOIN_DATA_MOUNT/bitcoinWhere the Bitcoin datadir is mounted; used to resolve the IPC socket.
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.pyPath to the Anker bridge script.
DVB_WARPPOOL_ANKER_REPO/opt/dvb-warppool/anker-solix-apiCheckout of the anker-solix-api library.
DVB_WARPPOOL_ANKER_PYTHON(auto)Python interpreter for the bridge; without it, venv detection with a python3 fallback.

CLI Override

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

dvb-warppool-daemon \
  --config /etc/dvb-warppool/config.toml \
  --secrets /etc/dvb-warppool/secrets.toml \
  --data-dir /var/lib/dvb-warppool \   # DB lives at <data-dir>/warppool.db
  --ui-dir /usr/share/dvb-warppool/ui \
  --no-zmq                          # debug-only: force poll-only mode

Reload Behavior

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

systemctl restart dvb-warppool

The only hot-switchable items are:

  • Profile (POST /api/admin/profile) — resizes the connection cap, the VarDiff range, the miner-poll interval, the retention TTLs and the rate limits live (see the live / restart / guide table above)
  • Announcement banner (POST/DELETE /api/admin/banner) — effective immediately, and self-expiring
  • Manual IP bans (POST /api/admin/ratelimit/ban) — effective immediately
  • Suppressed health warnings (Admin → Health) — DB-backed after first start
  • Notifier credentials (POST /api/admin/notifier/config) — written to secrets.toml
  • Admin tokens (POST /api/admin/tokens) — effective immediately
  • 2FA (POST /api/auth/2fa/enable) — effective immediately

See Also