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. The sum of fee + donation must not exceed 99%.

When address_allowlist is non-empty, a miner whose payout address (everything before the first . of the worker name) is not listed is rejected — so only your own / invited miners can connect. Empty = open (the default). This gates both transports: V1 mining.authorize, and Stratum-V2 channel-open (rejected with an OpenMiningChannelError). On V2 a miner additionally needs the pool’s NOISE authority key to connect at all.

Public wallet pages & the owner panel have no config. In per_worker mode each miner gets a login-free page at /users/<address> (ckpool-compatible JSON at GET /api/users/<address>). The opt-in owner panel — prove address ownership by signing a BIP-137 challenge, then toggle privacy or set a per-wallet notify target — is driven entirely per miner and is only reachable after that miner verifies ownership; there is no operator key here to set. Do not confuse this with two unrelated settings above: [legal] privacy_url is a legal-footer link for the whole pool (terms/privacy policy), not a per-wallet control, and address_allowlist gates who may mine, not who may see a page. See Public 0% Solo Pool and Notifications.

[node]

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

template_source = "gbt"      # "gbt" (default) | "ipc" — see below
ipc_socket_path = ""         # required when template_source = "ipc"
ipc_fee_delta_sats = 0       # ipc only; 0 = off

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

template_source picks where block templates come from:

  • "gbt" (default)getblocktemplate over RPC, refreshed on ZMQ block events plus the job_refresh_secs poll. Works with every Bitcoin Core release.
  • "ipc" — the Cap’n-Proto mining interface of a multiprocess Bitcoin Core ≥ 31 (bitcoin-node -ipcbind=unix), which pushes a new template via waitNext instead of being polled. Opt-in, and Core’s own IPC mining interface is itself experimental — treat this mode as a prototype and keep "gbt" for an unattended pool.

RPC stays mandatory in both modes (submitblock, health checks, mintime). Config validation rejects any other value, and rejects template_source = "ipc" without an ipc_socket_path.

ipc_socket_path is the -ipcbind Unix socket; Core’s default is <datadir>/<network>/node.sock and a unix: prefix is accepted. Being a Unix socket, pool and node must share a host or a volume (on Umbrel: the Bitcoin app from v1.3.0 with the IPC Mining Interface toggle and Core v31.0 — mount the datadir read-only and resolve the socket inside it).

ipc_fee_delta_sats (ipc only) additionally pushes a fresh template as soon as the template’s fees rise by at least that many sats. 0 (default) = off, which keeps the exact GBT cadence (tip change + job_refresh_secs poll). Values above 2100000000000000 (21 M BTC in sats) are rejected.

Both fields are also settable from the UI under Admin → Bitcoin Core → Template source, including a socket precheck.

[[node.backup]] — multi-node failover

Optional: any number of backup Bitcoin Core nodes. The [node] block is the primary; each [[node.backup]] entry adds a fallback:

[[node.backup]]
rpc_url = "http://192.168.178.99:8332"
rpc_cookie_path = "/mnt/node-b/.bitcoin/.cookie"      # auth option 1
zmq_hashblock_addr = "tcp://192.168.178.99:28332"     # empty = no ZMQ from this node

Per-backup auth resolution, in order: rpc_cookie_path → user:pass embedded in rpc_url (http://user:pass@host:8332 — note this lives in config.toml, mind the file permissions) → the global secrets.rpc_user/rpc_pass.

Failover semantics:

  • Sticky with preferred primary. Calls run against the last working node; on transport, auth or warmup errors they hop to the next within the same call (the job loop never stalls). While running on a backup, roughly once a minute one call probes the primary first and switches back as soon as it answers.
  • submitblock goes to ALL nodes in parallel — the block moment is the one instant where a dead node can cost real money; any Accepted wins.
  • ZMQ subscribes to every node that has a zmq_hashblock_addr; the job loop deduplicates block events by hash, so whichever node reports first triggers the refresh.
  • Notifications: a node switch fires node-failover (delivered to sinks that subscribed to on_rpc_down); rpc-down itself now means “no node reachable at all” — a pool running happily on a backup is degraded, not down, and the health snapshot carries a warning instead.

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

[server]

[server]
pool_listen = ":3333"
status_listen = ":18334"
status_tls_listen = ":18443"          # optional
status_public_url = "https://pool.example.com"   # for push notifications
trust_proxy_headers = false           # only set true when the daemon runs behind Caddy/nginx
cookie_secure = false                 # set true when the UI is served via HTTPS
# job_refresh_secs = 90               # template poll interval, range 10-600 (default 90)
# suppress_health_warnings = ["bitcoin_pruned"]   # hide these health-banner warnings

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

job_refresh_secs overrides the --refresh-secs CLI argument and accepts 10–600 seconds. The default of 90 s is deliberate: every mining.notify makes an ESP32-class miner (NerdMiner V2, ~250 KH/s) abort its hash loop and reload the coinbase, which resets its displayed hashrate. ASIC miners don’t feel the trade-off either way.

suppress_health_warnings is an opt-in list of Bitcoin Core health-warning keys the dashboard banner should not show — handy for a deliberately pruned node (bitcoin_pruned). It is only the initial value: on first start it seeds a database-backed list that you then manage under Admin → Health (or via the “Hide permanently” button on the banner). Known keys include bitcoin_pruned, bitcoin_no_inbound_listen, bitcoin_no_zmq_hashblock and bitcoin_few_peers.

Running behind nginx or Caddy? See Reverse Proxy for ready-to-paste configs (TLS termination, X-Forwarded-For, and the /api/events SSE passthrough) and how trust_proxy_headers / cookie_secure fit in.

[stratum]

[stratum]
stratum_tls_listen = ":3334"          # optional, V1 TLS
stratum_password = ""                 # empty = address-format auth only
safe_mode = true                      # Conservative validator
tls_cert_path = "/etc/dvb-warppool/cert.pem"
tls_key_path = "/etc/dvb-warppool/key.pem"
tls_reload_secs = 300                 # re-read cert/key on mtime change; 0 = off
sv2_listen = ":34254"                 # optional, Stratum V2
sv2_max_connections = 1024
max_connections_per_ip = 0            # V1 per-IP concurrent cap; 0 = off (Kern-Politur)

max_connections_per_ip bounds how many simultaneous V1 connections (plain + TLS) a single source IP may hold — independent of the global connection cap. 0 (default) is off; loopback is always exempt. Useful on a public pool to stop one host from hogging connection slots, while NAT’d home setups (many miners behind one IP) usually leave it at 0.

Sv2 needs an authority key in secrets.sv2_authority_priv_key_hex (32-byte x-only secp256k1, 64 hex chars). Since v1.0.15 the daemon auto-generates and persists it on first start; only when the secrets file is not writable is Sv2 disabled with a warning (the daemon still starts).

When tls_cert_path/tls_key_path are unset, the daemon generates and reuses a self-signed certificate under <data>/tls/ (SANs: dvb-warppool, dvb-warppool.local, umbrel.local, localhost; downloadable via GET /api/tls-cert and from the connect modal). Point the two paths at your own files to serve a CA-signed certificate instead — needed for miner firmwares that only trust public CAs (see Troubleshooting → TLS).

Renewal is handled by whatever issued the certificate (Caddy, certbot, your PKI). The daemon watches both files and re-reads them when their mtime changes, so an ACME renewal takes effect without a restart and without dropping miners: existing connections keep the certificate they negotiated, new handshakes get the fresh one. tls_reload_secs sets how often the check runs (default 300 s; 0 disables it and restores the old read-once-at-startup behaviour). If a reload fails — a half-written file mid-renewal, say — the previous certificate stays in service and the next check retries, so a bad write never takes the listener down.

[tuning]

[tuning]
max_saved_workers_per_user = 64
max_saved_workers_pool_total = 1024
active_worker_window_secs = 3600      # dashboard "active" window (default 1h)

Storage cap. Above these thresholds, old/idle workers are evicted (FIFO by last_seen_at). active_worker_window_secs controls how long a worker keeps counting as “active” on the dashboard after its last share.

[mining.auto_profile]

[mining.auto_profile]
enabled = false                # opt-in
evaluation_interval_secs = 60
hysteresis_ticks = 5           # consecutive ticks before a switch

Automatic profile switching based on the active miner count. Disabled by default; the admin UI’s manual hot-switch always works regardless.

[profile]

[profile]
kind = "klein"                  # klein|mittel|gross|enterprise (Small/Medium/Large/Enterprise); omit = auto
expected_miner_count = 5        # for auto: estimate used by auto-detection

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

A profile switch also applies that profile’s VarDiff range (initial_diff / min_diff / max_diff, shown per profile on the Hardware page) to the running pool — the change takes effect for new connections immediately and is persisted, so a restart derives the same values. Earlier this was decoupled: the table showed a profile’s VarDiff but the live pool kept the [vardiff] config values. The other VarDiff fields (target_seconds_per_share, window, hysteresis, max_step, idle_*) are not touched by a profile switch. If you set [vardiff] values by hand in config.toml and never switch profiles at runtime, your hand-set values stand.

Beyond VarDiff and the connection cap, a profile switch also drives the miner-poll interval, the retention TTLs, the Stratum-auth rate limit and the HTTP-API rate limit — live, and persisted so a restart re-derives them. The Hardware comparison table labels each row with which behaviour applies:

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

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

Profile defaults at a glance

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

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

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

What each row means:

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

[vardiff]

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

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

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

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

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

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

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

[ratelimit]

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

# Auto-ban: a public IP that keeps tripping the connect limit gets a full,
# escalating ban instead of just being throttled. Loopback + private LAN are
# never banned (local miners can reconnect in bursts).
autoban_enabled = true
autoban_strikes = 10       # connect-limit violations within the window …
autoban_window_secs = 60   # … that trigger a ban
autoban_base_secs = 300    # first ban 5 min; doubles per repeat …
autoban_max_secs = 3600    # … capped at 1 h

# Reject-ratio ban (Kern-Politur) — ban a worker that floods bad shares.
reject_ban_enabled = false      # off by default
reject_ban_ratio = 0.5          # rejected/(accepted+rejected) over this …
reject_ban_min_samples = 50     # … once at least this many shares are seen → ban + drop

# Manual (permanent) ban list — operator-curated. Also editable live via the
# admin API / the Admin → Profile page. Applies to any IP except loopback.
manual_bans = ["203.0.113.7", "198.51.100.42"]

# IP-gate (Kern-Politur) — subnet-level access control, off by default.
ban_cidrs  = ["203.0.113.0/24"]   # deny whole ranges (CIDR or bare IP)
allow_only = false                # true = strict whitelist (only allow_cidrs connect)
allow_cidrs = ["192.168.0.0/16"]  # the whitelist (used only when allow_only = true)

Per-peer-IP token bucket. Triggered limits return mining.authorize with result: false (no disconnect — the miner should not get stuck in a reconnect loop). Auto-ban goes one step further for public IPs: after autoban_strikes connect-limit violations within autoban_window_secs, the IP is rejected outright (before TLS/handshake) for an escalating duration. Set autoban_enabled = false to keep pure throttling. Loopback and private-LAN addresses are exempt by design, so your own miners are never locked out.

The IP-gate adds subnet-level control on top of the single-IP manual_bans: ban_cidrs refuses whole ranges, while allow_only = true flips to a strict whitelist where only allow_cidrs (plus loopback) may connect — handy for a private / invite-only pool. Both take CIDR ranges or bare IPs; malformed entries are dropped (logged) at start; loopback always passes so you can’t lock yourself out. All off by default.

Reject-ratio ban catches a different abuser: a worker that connects fine but floods invalid shares. With reject_ban_enabled = true, once a session has sent at least reject_ban_min_samples shares and its rejected fraction is ≥ reject_ban_ratio, that (public) IP is banned for the escalating auto-ban duration and the connection dropped. Public-IP-only, same as the connect auto-ban — your own LAN/loopback miners are never caught.

[notifier]

See Notifications for detailed sink setup guides.

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

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

[notifier.telegram]               # supports on_block_found + on_health_alert only
bot_token_env = "TELEGRAM_BOT_TOKEN"
chat_id = "123456789"
on_block_found = true
on_health_alert = true

[notifier.discord]                # supports on_block_found + on_health_alert only
webhook_url_env = "DISCORD_WEBHOOK_URL"
on_block_found = true
on_health_alert = true

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

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

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

[[webhooks]]

Generic outbound webhooks: the pool POSTs a JSON payload to each configured URL whenever a matching pool event fires. This is the push counterpart to the /api/events SSE pull stream — see Add-ons & Sidecars for when to use which. Repeat the block for multiple targets; omit it entirely and nothing is sent.

[[webhooks]]
url = "http://127.0.0.1:9000/ingest"     # http:// or https:// ONLY
secret_env = "WARPPOOL_WEBHOOK_SECRET"   # optional; enables HMAC signing
# events = [...]                          # omit → default low-frequency set
enabled = true                            # default true

# A second target, opting into high-frequency events:
[[webhooks]]
url = "https://example.com/hook"
events = ["block_found", "health_snapshot", "new_job", "shares_accepted"]
FieldRequiredDescription
urlyesTarget URL. Only http:// / https:// are accepted.
secret_envnoName of the env var holding the HMAC secret (never inline). When set, every request carries X-WarpPool-Signature: sha256=<hex>.
eventsnoList of event type strings to deliver. Empty → default set (low-frequency only).
enablednoDefault true. Set false to keep the config but stop sending.

Payload (Content-Type: application/json):

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

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

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

  • block_found, health_snapshot, update_available, profile_switched

High-frequency eventsnew_job and shares_accepted fire constantly (a new_job per template, an aggregated shares_accepted up to once a second). Only list them if your consumer can handle the volume; otherwise prefer the default set or the SSE stream. (heartbeat is also available but rarely useful for a webhook.)

Delivery semantics: each POST has a ~10 s timeout and is retried with exponential backoff (3 attempts total). If all attempts fail the event is logged (webhook dropped after retries) and dropped — there is no unbounded buffer, so a down consumer cannot back up the pool.

No SSRF protection — by design. Loopback / LAN targets are explicitly allowed because local sidecars are the main use case. The URL comes from this operator-trusted config, so only the URL scheme is validated. Do not point a webhook at an untrusted URL.

[retention]

[retention]
shares_raw_secs = 3600                    # per-share rows before aggregation (1 h)
shares_agg5_secs = 604800                 # 5-min share buckets (7 d)
miner_telemetry_raw_secs = 3600           # per-poll vendor probe results (1 h)
miner_telemetry_agg5_secs = 604800        # 5-min telemetry buckets (7 d)
workers_secs = 7776000                    # idle worker rows — block-finders kept forever (90 d)
audit_log_secs = 7776000                  # admin actions / logins / token mints (90 d)
api_tokens_retired_secs = 7776000         # grace AFTER revoke/expiry before the row is dropped (90 d)
push_subscriptions_stale_secs = 7776000   # grace after the LAST failed push attempt (90 d)

All values are TTLs in seconds; eviction runs inside the daemon’s aggregate loop (raw/agg tables every minute, audit/tokens/push hourly). Defaults match the values that were hardcoded before the block existed — omitting the section changes nothing. workers_secs bounds the one table a client can grow by reconnecting under fresh worker names: idle worker rows are evicted after the TTL, but any worker that ever found a block is kept forever. Two entries are conditional rather than age-based: active API tokens never age out (api_tokens_retired_secs counts from revocation or expiry), and push subscriptions are only dropped when their most recent delivery attempt failed (healthy but rarely-pushed subscriptions survive — on a solo pool the next real push may be months away; 410 Gone endpoints are deleted immediately by the push path itself).

[upnp]

[upnp]
enabled = false              # default off — opt-in
renew_interval_secs = 3000   # 50 min, safely under the default 3600 s lease

[[upnp.forwards]]
port = 3333
protocol = "tcp"             # "tcp" or "udp"
lease_seconds = 3600         # lease requested on every renew (60..=86400)
description = "dvb-WarpPool Stratum V1"

Phase 27: the setup wizard opens forwards once with a 1 h lease; if the pool runs longer, the daemon has to renew them. With enabled = false (default) no UPnP discovery task is started at all. Renewals are idempotent on the router side; the first renew tick runs ~10 s after boot so a crashed daemon recovers its forwards quickly.

[backup] — in-daemon auto-backup

[backup]
enabled = false        # master switch (default off)
interval_secs = 86400  # seconds between backups (clamped to >=60); default daily
dir = "backups"        # output directory (created if missing; relative to CWD)
keep = 7               # keep the newest N snapshots, prune older; 0 = keep all

When enabled = true the daemon writes a JSON ledger snapshot — the exact same format as GET /api/admin/backup (workers, blocks, miners, vardiff, settings; no secrets or raw shares) — to dir every interval_secs, named warppool-backup-<timestamp>.json, and prunes to the newest keep. Off by default: operators who already cron the API endpoint (or back up the SQLite file externally) simply leave it disabled. Note this is unrelated to [[node.backup]], which is Bitcoin-node failover.

[mining.electricity]

Note the path: electricity lives inside [mining], so the table headers are [mining.electricity], [[mining.electricity.tou]] and [mining.electricity.solar] — a bare [electricity] block is ignored.

[mining.electricity]
default_rate_eur_kwh = 0.30  # 0.0 = no tariff known, watt tracking only

[[mining.electricity.tou]]   # optional time-of-use slots, first match wins
name = "HT"
rate_eur_kwh = 0.35
hours = "06:00-22:00"        # wrapping slots like "22:00-06:00" work
weekdays = ["mon", "tue", "wed", "thu", "fri"]   # empty/omitted = every day

[mining.electricity.solar]
enabled = false
kind = "home_assistant"      # or "anker", "generic_http"
url = "http://127.0.0.1:8765/snapshot.json"   # direct URL (sidecars); takes precedence over url_env
url_env = "WARPPOOL_HA_URL"  # env var holding the provider base URL (cloud endpoints)
token_env = "WARPPOOL_HA_TOKEN"               # auth token env var (HA long-lived token, ...)
pv_entity_id = "sensor.pv_power_total"        # required for kind = "home_assistant"
consumption_entity_id = "sensor.house_power"  # optional; omitted → compare against pool power
poll_interval_secs = 60
excess_rate_eur_kwh = 0.0    # rate while PV excess covers the pool
surplus_buffer_w = 200.0     # excess must exceed pool power by this margin (anti-flap)
stale_after_secs = 300       # snapshot older than this → fall back to TOU/default

Drives the cost figures on the energy card: the effective rate per 5-minute bucket is solar-excess (if active), else the first matching TOU slot, else default_rate_eur_kwh. Solar provider credentials for the daemon-managed Anker bridge live in secrets.toml (anker_email/anker_password/anker_country), not here.

Announcement banner (runtime, not config.toml)

Since v1.21.0. The scrolling announcement strip under the public pool header (“maintenance tonight 22:00”, “welcome, new miners”) is runtime state, not a config key: it is set from Admin → Banner or over the API, takes effect without a restart, and expires by itself.

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

POST validation — all violations return 400:

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

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

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

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

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

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

secrets.toml (chmod 600)

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

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

Credentials written by the admin UI

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

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

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

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

Phase 21: VAPID Web Push Secrets

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

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

Environment Variables

Bitcoin Health Check

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

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

Auto-Update

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

Stratum / Notifier Tuning

VarDefaultDescription
WARPPOOL_DISCONNECT_DEBOUNCE_SECS30Per-worker debounce for MinerDisconnect notifications.

Solar/PV Provider (Phase 20.5)

The electricity tariff switches to excess_rate_eur_kwh (typically 0.0) when the PV array produces more than house + pool consume together. Supported kind values: anker (daemon-managed Anker Solix bridge — enter your Anker credentials under Admin → Photovoltaik, the pool runs the bridge itself), home_assistant (REST API, shown below), and generic_http (any endpoint serving the snapshot JSON).

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

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

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

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

Vendor Probes (Phase 22)

VarDefaultDescription
WARPPOOL_AUTO_PROBE_DISCOVERED(off)1/true/yes: in addition to DB miners, devices found via mDNS are polled every 30s. Telemetry values land in an in-memory cache (no DB write) and are exposed in /metrics with label="discovered".

Health Anomaly Check (Phase 20.3b)

VarDefaultDescription
WARPPOOL_ANOMALY_CHECK_INTERVAL_SECS300Interval of the periodic anomaly detection. 0 = off.
WARPPOOL_ANOMALY_DEBOUNCE_SECS1800Per-(miner, alert_kind) debounce. While a symptom persists, the notifier does not fire on every tick — only again after this interval.

Critical alerts (FanStuck, StaleData) are sent to every sink with on_health_alert = true (default). Warnings (ThermalThrottling, VoltageDrop, HashrateDrop) are UI-only via /api/miners/:id/alerts.

Notifier Secrets

The variable names are FREELY CHOSEN — you set the name in config.toml, and the daemon reads that env var.

Typical names:

  • TELEGRAM_BOT_TOKENnotifier.telegram.bot_token_env
  • DISCORD_WEBHOOK_URLnotifier.discord.webhook_url_env
  • SLACK_WEBHOOK_URLnotifier.slack.webhook_url_env
  • POOL_SMTP_PASSWORDnotifier.email.password_env

Test/Debug

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

Deployment / container overrides

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

VarDefaultDescription
DVB_WARPPOOL_STRATUM_TLS_LISTEN(unset)Overrides stratum.stratum_tls_listen. The --no-tls CLI flag still wins.
DVB_WARPPOOL_SV2_LISTEN(unset)Overrides stratum.sv2_listen.
DVB_WARPPOOL_DEPLOY(unset)umbrel / docker — tells the daemon it runs in a container, so it stops looking for a host bitcoin.conf and reports Bitcoin Core as external.
DVB_WARPPOOL_BITCOIN_DATA_MOUNT/bitcoinWhere the Bitcoin datadir is mounted; used to resolve the IPC socket.
WARPPOOL_DATA_DIR(platform default)Overrides the data directory used for the install marker.
WARPPOOL_DISABLE_MDNS(off)Turns mDNS miner discovery off entirely.
DVB_WARPPOOL_ANKER_BRIDGE/opt/dvb-warppool/anker-bridge/anker-bridge.pyPath to the Anker bridge script.
DVB_WARPPOOL_ANKER_REPO/opt/dvb-warppool/anker-solix-apiCheckout of the anker-solix-api library.
DVB_WARPPOOL_ANKER_PYTHON(auto)Python interpreter for the bridge; without it, venv detection with a python3 fallback.

CLI Override

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

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

Reload Behavior

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

systemctl restart dvb-warppool

The only hot-switchable items are:

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

See Also