Roadmap
How to read this file. Everything below Phase 32 is a historical build log, kept deliberately: each phase records what was done, which bugs were found, and why decisions went the way they did. The “THIS SESSION” markers are historical, not current. For where the project stands today, read the two sections directly below.
Current status — v1.24.0
Release v1.24.0, 1201 Rust tests + 4 ignored, svelte-check across
374 files. Source, container registry and releases live on the project’s
own Forgejo at git.warppool.org; the handbook is published at
docs.warppool.org.
pool.warppool.org runs the public per-worker, 0 % fee, non-custodial
mode on mainnet with roughly a dozen miners. No mainnet block has been
found yet — the pool is young, and this line gets updated when that
changes, not before.
Shipped since Phase 32
These landed after the phase log below was written; they are done, not planned:
- Per-worker coinbase (Model A, non-custodial) — every miner mines to
its own address taken from the Stratum login; full reward, one output,
no fee, invalid address is refused.
[mining].payout_mode = "per_worker". Verified end-to-end against Bitcoin Core regtest (block accepted, correct attribution, witness commitment, no cross-pay). Design doc:docs/design/per-worker-coinbase.md; operator guide: Public 0% Solo Pool. - Sv2 modernisation to the 2024 spec — secp256k1 + EllSwift + Schnorr NOISE-NX; live-verified against Bitaxe / NerdQAxe hardware.
- Bitcoin Core IPC template source — opt-in
[node].template_source, Cap’n Proto against Core ≥ 31, GBT+ZMQ stays the default (v1.16.0). - Setup wizard with i18n in 8 languages — plus a locale dropdown; the whole UI is i18n-guarded in CI (no hardcoded strings).
- Donation support — configurable donation addresses, admin surface at
/admin/donations(v1.19.0). - Outbound webhooks + read-only API token scopes — see Add-ons & Sidecars (v1.7.0).
- Backup section — export/restore of the SQLite database from the admin UI.
- Umbrel delivery — community store plus the official
umbrel-appssubmission. - Scrolling banner — operator-editable marquee announcement bar,
admin surface at
/admin/banner(v1.21.0).
Known open items
- Sv2 Job Negotiation / Job Declaration — not implemented. Only the Sv2 Mining Protocol ships; miners cannot select their own transaction set. Not currently scheduled.
- Windows
.msi— paused. There has been no Windows build runner since the forge move, so the release pipeline skips the job. The WiX sources (packaging/windows/) stay maintained and the artifact returns when a runner exists. - macOS
.dmgin CI — likewise no macOS runner; the.dmgis built by hand per release viapackaging/macos/build-macos-release.sh. - Phase B signing — macOS notarization (Developer ID) and Windows code-signing (EV certificate) are both still open.
Phase 1 — Foundation (THIS SESSION ✓)
- Workspace layout, Cargo.toml, .gitignore, .cargo/config.toml
-
warppool-profilescrate (Small/Medium/Large/Enterprise) — implemented + tested -
warppool-configcrate (schema port from dvb-gopool) — implemented + tested -
warppool-hwdetectcrate — hardware auto-detection + profile recommendation - Coinbase splits (pool fee + donation, default “No Pool Fee”, relevant for Large/Enterprise / multi-user solo)
-
warppool-simulatorcrate (sim-miner / sim-node / failures / load / security / scenarios) - Stub crates with module docs as roadmap markers
- Apps:
dvb-warppool-daemon,dvb-warppool-cli,dvb-warppool-sim— skeleton
Phase 2 — MVP “runnable” (complete, covered by 2.1/2.5 + Phase 3)
Goal: a real solo pool for 1-5 miners, plain Stratum V1, no TLS.
-
warppool-bitcoin-rpc— RPC + ZMQ subscribe (see Phase 2.1) -
warppool-job-builder— GBT conversion + coinbase -
warppool-share-validator— SHA256d check -
warppool-stratum-v1— plain TCP listener (TLS later in Phase 3) -
warppool-storage— SQLite + migrations + raw shares -
warppool-api— minimal/api/overview+/healthz -
dvb-warppool-daemon— wire it all together + graceful shutdown -
dvb-warppool-sim—solo-block-foundscenario functional (see Phase 3)
Phase 2.5 — VarDiff (THIS SESSION ✓)
-
warppool-stratum-v1::vardiff— EMA-based algorithm with hysteresis, max_step, min/max clamping -
warppool-storage—vardiff_statetable + load/save -
Sessionintegration — load on Authorize, observe on Accept, retarget with set_difficulty + pool-target update, async save -
[vardiff]config section +StorageVarDiffStoreadapter in the daemon - 26 new tests (vardiff core 17 + storage 3 + config 3 + e2e wiring 1 + daemon adapter 1 + e2e snapshot-restore test 1)
Phase 2.1 — ZMQ Subscribe (THIS SESSION ✓)
-
warppool-bitcoin-rpc::watch_hashblock— pure-Rustzeromqcrate, no libzmq required - Reconnect backoff 1s → 30s on disconnect
- 3-frame parser for
hashblock(topic / 32-byte BE hash / 4-byte LE seq) - Daemon: optional ZMQ-watcher task →
mpsc::channel<ZmqBlockEvent>→job_refresh_loopselect arm. Polling remains as fallback. -
--no-zmqCLI flag for polling-only mode - 6 new tests (parse 4 + hash_hex + real PUB/SUB roundtrip)
Phase 3 — Security & Stability
- TLS for Stratum V1 (THIS SESSION) —
Sessiongeneric overAsyncRead+AsyncWrite,tls::load_tls_config(rustls + aws-lc-rs),StratumServer::serve_tls_with_listenerwithtokio-rustls::TlsAcceptor, daemon optionally spawns a parallel TLS listener whenstratum_tls_listen+ cert/key are set. CLI flag--no-tls. E2E roundtrip with rcgen-generated self-signed cert green. - Rate limiting (THIS SESSION) — Token bucket per peer-IP in
ratelimit::RateLimiter, two buckets (connect + auth), lazy cleanup for idle entries. In theacceptloop before the semaphore check, inhandle_authorizebefore the auth work.[ratelimit]config section. E2E test triggers auth burst-block. - Auth Subphase 1 — Admin login + JWT session (THIS SESSION). Argon2id password hashing, HS256 JWT, cookie + Bearer header. Routes: POST
/api/auth/login, POST/api/auth/logout, GET/api/auth/whoami, GET/api/admin/ping(protected demo). AuthIdentity extractor: 401 without token, 503 when auth is not configured.dvb-warppool-cli hash-password+gen-jwt-secretsubcommands for setup. Whenadmin_password_hashorjwt_secretin secrets.toml are empty → auth is off, daemon logs a warning. - Wire CLI to the API (THIS SESSION) — all TODOs replaced with real reqwest calls. New subcommands:
status,miners --limit,blocks --limit,profile,hashrate --worker --hours,login -u,whoami.--jsonglobal flag for scripting. Pretty-print with humanized hashrate units (H/s → EH/s). - Sim-Runtime: solo-block-found for real (THIS SESSION) —
JsonStratumMineras a real TCP Stratum V1 client, persona-driven submit rate with Poisson jitter + 50ms cap.dvb-warppool-sim scenario solo-block-found --duration Nspawns in-process Stratum server (network_target=[0xff;32]) + sim-miner, waits for BlockFoundEvent, prints report. - Sim-Runtime: connection-storm-defense for real (THIS SESSION) —
dvb-warppool-sim scenario connection-storm-defense --attackers Nspawns Stratum server with aggressive auth rate-limit (burst=10, 1/s) + N parallel TCP attackers. Validates the Phase-3 rate-limit live: 200 attackers → 10 success, 190 rate-limited. - Sim-Runtime: enterprise-load-smoke for real (THIS SESSION) —
dvb-warppool-sim scenario enterprise-load-smoke --miners Nspawns N parallelJsonStratumMinerwith mixed personas (round-robin across 4 vendor profiles). Throughput test: 200 miners deliver ~3800 shares/s stable over 15s, 0 rejected. - Auth Subphase 2 — API tokens (THIS SESSION) —
api_tokenstable with SHA-256 hash (plaintext never persisted), formatwpat_<32hex>.mint_api_token()+sha256_hex()in api/auth.AuthIdentityextractor distinguisheswpat_prefix → DB lookup viafind_active_token_by_hash; otherwise continues with JWT. Routes: POST/GET/DELETE/api/admin/tokens(with audit log). Soft-delete viarevoked_at. CLI subcommandstoken-create -n -- --ttl --scope/token-list/token-revoke <id>. - Auth Subphase 3 — 2FA-TOTP (THIS SESSION) —
admin_2fatable per user (secret_base32, enabled, created_at, enabled_at). totp-rs crate (SHA-1, 6 digits, 30s, ±1 skew). Routes: GET/api/auth/2fa/status, POST/setup(mint secret + otpauth URL),/enable(verify code + set enabled),/disable(verify code + delete). Login optionally acceptstotp_code; required when 2FA is active. 401 withrequires_2fa: trueflag when code is missing. CLI subcommandstwofa-status/setup/enable/disable,login --totp CODE. Phase 3 complete with this. - Profile hot-switch + X-Forwarded-For (THIS SESSION) —
pool_settingsKV table,AppState.profile_kindis nowArc<RwLock<ProfileKind>>. POST/api/admin/profile {kind}persists + audits + immediately visible in/api/profile//api/overview. Daemon reads persisted profile on startup (fallback config).server.trust_proxy_headers(default false) — when true: peer_ip is taken fromX-Forwarded-Forheader (first element). Protects against IP spoofing on direct internet access. - Tiered retention + aggregation worker (THIS SESSION) —
shares_raw+shares_agg_5mintables,record_share+aggregate_5min+ evict methods in storage.ShareSinktrait in stratum-v1 (Session calls sink.record after Valid/BlockFound in a spawned task).StorageShareSinkadapter in the daemon. Aggregate loop every 60s with eviction (raw >1h, agg_5min >7d)./api/hashrate?worker=…&hours=…endpoint with 5-min-bucket output + approx_hashrate. - Audit log (THIS SESSION) —
audit_logtable (id/at/actor/action/target/peer_ip/ok/details).AuditSinktrait inwarppool-api::auth+StorageAuditSinkadapter in the daemon. Auth routes firelogin.ok/login.fail. Protected endpointGET /api/admin/audit?limit=N&actor=.... Peer-IP via optionalConnectInfo<SocketAddr>(daemon usesinto_make_service_with_connect_info::<SocketAddr>). CLIaudit --limit --actorsubcommand with table print. - CI: fmt, clippy, test, deny (in
.github/workflows/ci.yml+deny.toml)
Phase 4 — UX
- SvelteKit PWA with Modern UI palette (port from dvb-gopool) — Phase 1
- UI Subphase 1: Login + auth state (THIS SESSION) —
lib/auth.svelte.tswith Svelte 5 runes ($state in.svelte.tsmodule), cookie-based session (credentials:‘include’ on all fetches), localStorage holds only username + marker (token is HttpOnly cookie)./loginroute with username/password + optional TOTP code (appears afterrequires_2fa: trueresponse). Header shows login link or username+logout button. - UI Subphase 2: Admin cockpit (THIS SESSION) — Routes
/admin/profile(4-kind switcher),/admin/tokens(list + create + revoke with plaintext-once display),/admin/audit(table + actor/limit filter, tone-coded actions),/admin/2fa(status + setup wizard with secret/otpauth URL + code verify + disable).+layout.sveltewith auth guard (redirect /login if unauth). Header shows “Admin” link only when authed.lib/api.tsadmin.*sub-namespace with all endpoints. - UI Subphase 3: Hashrate chart (with
/api/hashrate,HashrateChart.svelte) - Live updates via SSE (
/api/events→lib/events.svelte.ts) — WebSocket variant rejected, SSE is sufficient for read-only push and avoids reverse-proxy pitfalls - i18n (DE/EN/JA/ZH) via
lib/i18n.svelte.ts+locales/*.json - Push notifications: foreground via Browser Notification API (
lib/notifications.svelte.ts); real VAPID web-push shipped in Phase 21 (pure-Rust backend —crates/notifier/src/webpush.rs+ the daemonpush_send_loop) - Mobile-responsive (header collapsed, all main routes OK)
Phase 5 — Operations (complete)
- Prometheus exporter
/metrics - Vendor API polling (AxeOS, NerdNOS, BitMain, Whatsminer)
- mDNS auto-discovery
- Notifier: ntfy + Telegram + Discord + Web Push + Email
- Backup/restore (config + DB)
Phase 6 — Packaging (complete, incl. RPi 5 first-class)
- Docker multi-arch (amd64 + arm64)
- Umbrel app manifest
- macOS .dmg (per-arch aarch64 + x86_64, unsigned — notarization is Phase B; not built in CI — there is no macOS runner since the forge move, so each release’s
.dmgis built by hand viapackaging/macos/build-macos-release.shand attached after publish) - Windows .msi (unsigned — code-signing is Phase B; not currently built — no Windows runner since the forge move, WiX sources maintained)
- Linux .deb / .rpm / .AppImage
- Tauri desktop bundle (optional)
- Raspberry Pi 5 first-class — dedicated arm64 builds, pinned for Pi 5
Phase 7 — Stratum V2
- 7.1 Foundation —
warppool-stratum-v2crate: codec (binary frame with compact_int), messages (SetupConnection, OpenStandardMiningChannel, SubmitSharesStandard, +Success/Error), noise (NOISE_NX_25519_ChaChaPoly_BLAKE2s viasnow) - 7.2 State machine —
MiningServerSessionwith AwaitSetup → MiningProtocol → Closed phases,ChannelRegistrywith per-channel extranonce prefix + duplicate-sequence detection - 7.3a TCP/NOISE server —
Sv2Serverwith accept loop, NOISE_NX handshake (responder), length-prefixed encrypted-frame I/O, JobUpdate broadcast - 7.3b Daemon wiring — Config + secrets.toml (static_priv_key persistent), daemon spawns V1+V2 in parallel when hosted, E2E test green
- 7.4 V1↔V2 translator proxy — Extended-channel messages (
OpenExtendedMiningChannel0x13,OpenExtendedMiningChannelSuccess0x14,SubmitSharesExtended0x1F,OpenMiningChannelError0x12,SubmitShares.Success0x1C,SubmitShares.Error0x1D) including server handler with real Success/Error responses (old Phase-7.3 TODOs resolved). PublicSv2Client(TCP+NOISE initiator, previously cfg(test) only). New cratewarppool-translator+ binarydvb-warppool-translator(clap CLI). Per V1 connection the translator opens a V2 connection, buffersmining.subscribeuntilmining.authorizearrives, then sendsOpenExtendedMiningChannelupstream.mining.submit→SubmitSharesExtendedwith miner-controlled extranonce;SubmitShares.Success/Error→ V1 OK/Err with correct Stratum codes. E2E test: mock-V1-miner → translator → Sv2 server → submit roundtrip. - 7.5b Translator production polish (THIS SESSION) —
prev_hashbyte order in V1mining.notifyis now slushpool-conformant (4-byte-chunk-reversed per group, so real V1 miners parse the hash correctly in sha256 block order — previously we passed BE display bytes through). BIP-320 version rolling:mining.configurehandler negotiates a mask between client and translator:agreed = miner_mask & ours_mask;ours_mask = DEFAULT_VERSION_ROLLING_MASK (0x1FFFE000, 16 bits Bit 13-28)if the upstream job reportsversion_rolling_allowed=true, otherwise 0. With agreed=0 → response{"version-rolling": false}.mining.submitparses optional 6th paramversion_bits_hex; with rolling active, the translator builds V2SubmitSharesExtended.version = (job.version & !mask) | (miner_bits & mask)(BIP-320 XOR). Previouslyversion: 0was hardcoded (pre-7.5b MVP bug). 4 new translator lib tests (prev_hash_to_v1_hex_reverses_4_byte_chunks, prev_hash_to_v1_hex_known_block_explorer_value, bip320_version_resolution_with_mask_applies_xor, bip320_default_mask_is_16_bits) + 2 new E2E (configure_negotiates_version_rolling_mask, configure_without_upstream_rolling_returns_false). 356/356 tests green (+6 vs 7.5). - 7.5 Job distribution + V1 notify mapping — Four new Sv2 messages:
NewMiningJob0x1E (for standard channels, with finished merkle_root),NewExtendedMiningJob0x22 (for extended, with version_rolling_allowed +SEQ0_255<B032>merkle_path + B0_64K coinbase_prefix/suffix),SetNewPrevHash0x20 (channel_id/job_id/prev_hash/min_ntime/nbits),SetTarget0x21 (channel_id/maximum_target). Sv2 server fan-out:JobUpdateextended with coinbase_prefix/suffix/merkle_root/min_ntime/version_rolling_allowed; in handle_connection job-broadcast arm,build_job_frames()builds the matching job variant + follow-upSetNewPrevHashfor each open channel (Standard → NewMiningJob+SNPH, Extended → NewExtendedMiningJob+SNPH); previously “log only”. Translator learns NewExtendedMiningJob + SetNewPrevHash + SetTarget; caches both job halves, pairs them byjob_id,maybe_emit_notify()sends V1mining.notifywith all 9 params (job_id/prev_hash_hex/cb1/cb2/merkle_branches/version_BE/nbits_BE/ntime_BE/clean_jobs);clean_jobs=trueon tip change (new prev_hash). SetTarget with unchanged target is skipped (avoids redundant set_difficulty); on change →mining.set_difficulty(target_to_difficulty). Tests: 2 server tests (push_job_emits_new_extended_mining_job + …new_mining_job), 2 translator lib tests (build_v1_notify 9 params + clean_jobs=false on same prev_hash), 1 new E2E (Sv2 handle.push_job → translator → V1 mining.notify with prev_hash×0x42 + hex-encoded cb1/cb2 “PREFIX-bytes”/“SUFFIX-bytes-yep” + merkle_path branches + clean_jobs true→false transition). 337/337 tests green (+11 vs 7.4 state). Remaining for 7.x: real version-rolling bit-mask handling (BIP-320), Standard-channel mining.notify (today the Standard job is emitted locally by the pool, the translator uses Extended), reorg edge cases. - 7.6a Template Distribution Foundation (THIS SESSION) — Seven Sv2 TDP messages (Phase 7.6a Foundation, no Bitcoin-node wiring):
CoinbaseOutputDataSize0x70 (Pool→Node),NewTemplate0x71 (Node→Pool, with future_template bit for pre-build, coinbase_prefix/outputs B0_64K, SEQ0_255 merkle_path),SetNewPrevHashTdp0x72 (note: separate wire format from Mining 0x20! template_id u64 + prev_hash + header_timestamp + nbits + target),RequestTransactionData0x73 (Pool→Node),RequestTransactionData.Success0x74 (withSEQ0_64K<B0_16M>transaction_list — u16 count + each tx u24-LE-prefixed),RequestTransactionData.Error0x75,SubmitSolution0x76 (Pool→Node with finished coinbase). Session state machine: new phaseSessionPhase::TemplateDistribution,handle_setupacceptsProtocol::TemplateDistributionProtocol(=2) and switches to the phase. TDP frames are accepted in the phase and acknowledged withtracing::debug!without response (Phase 7.6b wires the real handling). 10 new message roundtrip tests + 3 session tests (tdp_transitions_to_template_distribution, tdp_frames_accepted_without_response, mining_frames_in_tdp_phase_are_unexpected). 350/350 tests green (+13 vs 7.5). - 7.6b Template Distribution Wiring — shipped via Option B in v1.16.0 (see UPDATE at the end); the decision history that led there is preserved below. Bitcoin Core has decided against native Sv2 support (Issue #31098); instead, since Core 30.0 an experimental generic IPC Mining Interface is available — Sv2 functionality is to be implemented via a sidecar (e.g. SRI’s
sv2-tp-client), not directly in the node. Three paths exist today: (A) Sv2 TDP client against Sjors’ Bitcoin Core fork withbitcoin-sv2-tppackaging (uses Phase 7.6a messages 1:1, ~10-15 tasks); (B) IPC client against mainline Core 30+ via cap’n-proto bindings (~20+ tasks, Phase 7.6a will be orphaned for this path); (C) wait until the ecosystem (SRI vs IPC-only) stabilizes, 12-24 month horizon. Chosen: Option C — getblocktemplate+ZMQ via Phase 2.1 is functional and low-latency today; a switch would cost implementation effort without practical value for the pool operator. Re-eval when Bitcoin Core 31/32 marks the IPC API as stable or SRI’s stack becomes production-mature. Re-eval 2026-06-12 (v1.0.23): trigger NOT met. Core 31.0 again broke themining.capnpschema (clients built against older schemas fail hard onInit.makeMining;createNewBlock/checkBlockgained a requiredcontextparameter,getCoinbaseTxchanged shape) — the IPC interface is still iterating with breaking changes per release, the opposite of stable. The SRI side matured considerably (1.0 → 1.6, split intostratumlibs +sv2-appsbinaries/Docker with a production-ready release policy, new SV2-only solo mode), but its template-provider path sits on top of the same moving Core schema. Decision: stay on Option C; next re-eval at Core 32 — if the schema freezes there, Option A/B become concrete. UPDATE (v1.16.0, 2026-07-09): Option B shipped. An opt-inwarppool-bitcoin-ipctemplate source (Cap’n Proto against mainline Core >= 31) now feeds the samejob_refresh_loop/JobBuilder path through a sharedTemplateSourcetrait; GBT+ZMQ stays the default. Measured template latency ~5–12 ms vs ~90–100 ms for GBT+ZMQ (see benchmarks +docs/design/mining-ipc-backend.md). The Sv2 TDP path (Option A / the Phase 7.6a wire messages) remains deferred — the IPC backend talks capnp to Core directly and does not use the TDP messages, so 7.6a stays orphaned for this path. The schema-churn caveat is now a managed maintenance cost (schemas vendored + pinned per Core major).
Phase 21 — VAPID Web Push (complete)
- 21.1 VAPID crypto + CLI (THIS SESSION) — New
warppool-notifier::webpushmodule with pure-Rust crypto stack (p256for ECDH/ECDSA,ecev2 for AES-128-GCM Content-Encoding RFC 8188,jsonwebtokenfor VAPID ES256 JWTs, no openssl-sys).VapidKeys::generate()produces a P-256 keypair as base64url;WebPushSender::send(sub, payload, ttl)performs JWT sign + payload encrypt + HTTPS POST. CLI subcommandgen-vapid-keysprints a TOML snippet to stdout (+ pub-key to stderr for UI hint). secrets.toml fieldsvapid_public_key+vapid_private_key+vapid_contact. 6 unit tests. - 21.2 Subscribe + public-key API (THIS SESSION) —
GET /api/push/vapid-public-key(public, no-auth, returns base64url key for the UI’sPushManager.subscribe),POST /api/push/subscribe {endpoint, p256dh, auth, label?}(public so subscribe without login is possible, validates HTTPS endpoint + non-empty keys),DELETE /api/push/subscribe {endpoint},POST /api/push/test(admin-required, fires test payload to all subs, returns sent/failed/gone_removed counts). AppState extended withvapid_public_key+web_push_sender: Option<Arc<WebPushSender>>. - 21.3 Daemon push-send loop (THIS SESSION) —
push_send_loopsubscribes toPoolEventBus, fires on BlockFound/HealthSnapshot-with-warnings/UpdateAvailable. Per sub, spawn a task so a slow push service doesn’t block the others. OnWebPushError::Gone→delete_push_subscription_by_id(browser invalidated the sub). On other errors →touch_push_subscription(error)for UI debugging.render_push_eventhelper filters + renders events to title/body/tag tuples (returns None for events that should not be pushed). - 21.4 UI subscribe flow + service worker (THIS SESSION) — New
ui/static/service-worker.js(minimal SW with push + notificationclick + pushsubscriptionchange handlers, requireInteraction for block-found). NewwebPushstore innotifications.svelte.tswith subscribe()/unsubscribe()/refresh() + status machine (‘idle’|‘unsupported’|‘no-service-worker’|‘denied’|‘subscribed’|‘unsubscribed’). VAPID key is fetched at runtime from/api/push/vapid-public-key(no build-time secret). New BentoCard “Background Push (VAPID Web Push)” in/admin/notificationswith subscribe/unsubscribe button + iOS hint (required: add to home screen) + operator setup snippet. - 21.5 Tests + docs (THIS SESSION) —
notifications.mdnew “Web Push (PWA, VAPID)” section with operator setup + user subscribe flow + events that push + iOS quirks + security note.configuration-reference.mdnew secrets section for VAPID keys. ROADMAP.md amended. 520 tests green (+6 vs Phase 22.5, all 6 in the webpush module).
Phase 20 — Live Energy + Health Trend (complete, MVP)
-
20.1 Storage + electricity-tariff config (THIS SESSION) — New
ElectricityConfiginMiningConfig.electricitywith 3-layer priority (Solar/TOU/Default). Pure-fneffective_rate(now)+slot_matcheshelper, wrap-around slot support (22:00-06:00), weekday filter. Storage:miner_telemetry_raw(1h retention) +miner_telemetry_agg5(7d).record_miner_telemetry+aggregate_miner_telemetry_agg5+evict_*+miner_telemetry_history. Daemon wiring in miner_poll_loop + aggregate_loop. +13 tests (9 config + 4 storage). -
20.2 Live energy + history endpoints (THIS SESSION) —
GET /api/miners/:id/history?hours=N(auto-selects raw≤1h vs agg5) +GET /api/energywith current_power_w, current_eur_per_hour, last_{24h,7d}_kwh+eur, current_rate_source (“tou:HT”|“default”|“none”). Storage helperenergy_kwh_in_last_hours()aggregates across all miners. Pure-fnssum_current_power+describe_rate_sourceseparately testable. +8 tests. -
20.3 Health anomaly detector (THIS SESSION) — Pure-fn
detect_anomalies(points, now, thresholds)in newwarppool-storage::anomaliesmodule. 5 heuristics: StaleData (5min stale → Critical), FanStuck (rpm<1000 @ >30W consistent → Critical), VoltageDrop (<1050mV → Warning), ThermalThrottling (≥80°C + 15% hashrate drop → Warning), HashrateDrop (>30% drop without thermal context → Warning). ConfigurableAnomalyThresholds. Stale-data blocks other heuristics (no point analyzing an inactive miner). Thermal+Hashrate are deduplicated (no double-alert). API endpointGET /api/miners/:id/alerts. +13 tests (11 detector + 2 api). -
20.5 Solar HA provider (THIS SESSION) — Home Assistant REST API adapter.
SolarConfigextended withkind(“home_assistant”),url_env/token_envfor auth,pv_entity_id(required) + optionalconsumption_entity_id,poll_interval_secs(default 60),surplus_buffer_w(default 200W),stale_after_secs(default 300s),excess_rate_eur_kwh(default 0.0). NewSolarSnapshot {pv_w, consumption_w, at}+SolarSnapshotCache = Arc<RwLock<Option<...>>>type.effective_raterestructured:effective_rate_with_context(now, solar_snap, pool_power_w)is the new main procedure with 3-layer priority. API side: Pure-async-fnfetch_ha_entity_wparses HA’s/api/states/<id>response incl. unit conversion (W/kW/MW).fetch_ha_snapshotorchestrates PV + optional consumption./api/energyresponse extended withsolar: Option<SolarStatus>field (pv_w/consumption_w/excess_w/age_seconds) and newcurrent_rate_source = "solar-excess"value. Daemon:solar_poll_looptask spawns whensolar.enabled, writes to the shared cache. Failures are ignored (last-known snapshot stays until stale). +10 tests (7 config solar-logic + 3 api fetch_ha + 1 e2e solar-excess endpoint). 508 tests green (+10 vs Phase 20.4b). -
20.4b Per-miner detail page (THIS SESSION) — New dynamic route
/miners/[id]with header (host + vendor + last_error badge), health-alerts card (alerts from/api/miners/:id/alertswith severity tone), history card with range picker (1h/6h/24h/7d) + 5 TelemetrySparkline components (Hashrate/Power/Temp/Voltage/Fan). Refresh 30s.TelemetrySparkline.svelteas a simple SVG line chart, parameterizable viafield/label/fmtValue/yMin/accent(plasma/warp/btc/lime). Detail link/miners/[id]in the card-foot of the /miners list. 298 svelte-check files / 0 errors. -
20.3b HealthAlert notifier hook (THIS SESSION) — Closes the 20.3 gap. New
Event::HealthAlert{miner_label, alert_kind, severity, message}enum variant in warppool-notifier. All 5 sinks get anon_health_alert: boolconfig field (default true, Critical alerts indicate hardware damage). Sink-specific rendering: ntfy withrotating_lighttag + priority 5 for Critical; Slack with:rotating_light:emoji + severity label in body; Telegram uses render_text fallback. Daemon-sideanomaly_check_loop(env-gated:WARPPOOL_ANOMALY_CHECK_INTERVAL_SECS=300 default, 0=off). Per-(miner_id, alert_kind) debounce 30min (default,WARPPOOL_ANOMALY_DEBOUNCE_SECS) so not every 5-min tick fires a notification. Only Critical severity fires (FanStuck, StaleData) — Warnings stay UI-only. Tests from existing notifier suite (all 19 green after field extension). configuration-reference.md new env-vars table. -
20.4 UI EnergyCard (THIS SESSION) — New
EnergyCard.sveltecomponent on the dashboard with 4 StatTiles (Watt / €/h current / 24h kWh+€ / 7d kWh+€), tariff source display, “tariff not configured” hint when rate_source=“none”. Refresh cadence 30s in sync with miner_poll_loop. i18n keysenergy.*in de + en (six other locales get English fallback until translated). Per-miner history charts + alerts display deferred as 20.4b. svelte-check 295 files / 0 errors / 1 pre-existing warning.
Phase 32 — Security audit pass + re-verification (complete)
- 32.1 Audit methodology (THIS SESSION) — 5 parallel audit agents over ~31K LOC, one per attack surface (Funds/Consensus, Stratum-V1+Translator, Stratum-V2/NOISE, API-Authz/Auth, Data/Secrets/Supply-Chain). Every finding verified against the real code, then a second independent agent run for verification. 568 tests green (+12 vs 556), clippy
-D warningsclean. - 32.2 🔴 CRITICAL: Merkle root wtxid→txid + byte order (THIS SESSION) —
job-builderusedtx.hash(segwit wtxid) instead oftx.txidAND did not reverse the tx hashes from GBT display order into internal order. Consequence: on EVERY network with a non-empty mempool, wrong merkle root → Bitcoin Core rejects withbad-txnmrklroot, while the self-consistent pool-internal PoW check falsely reports “block won” = silent reward loss on mainnet. Latent because all live tests (Phase 29/31) used regtest with empty mempool. Fix:hex_to_32_reversed(&tx.txid). Known-answer test against real mainnet block 100000 + regression guard (txid-reversed vs raw-wtxid). - 32.3 HIGH fixes (THIS SESSION) — (a) V1+Translator line cap 16 KiB via
LinesCodec(was unbounded → OOM via 1 connection); (b) V1 pre-auth handshake timeout 60s (NO post-auth idle timeout, since low-HR miners at diff 1.0 may be idle up to ~24h/share); (c) Dedup-set leak:push_job→validator.clear_all()(was unbounded for the process lifetime); (d) Sv2 connection cap:max_connectionswas dead config → now a semaphore (0=unlimited); (e) Sv2 extranonce length check inprecheck_submit(invalid-extranonce-size); (f) unauth Web-Push SSRF:is_safe_push_endpoint(https + private/loopback/link-local/CGNAT/localhost block) on subscribe + send. - 32.4 MEDIUM/LOW fixes (THIS SESSION) — DB perms (data dir 0700 + DB+WAL+SHM 0600, unix — the documented chmod-600 assumption did not previously exist in code); setup wizard no longer overwrites existing
secrets.toml(was data loss: jwt_secret/sv2-key/vapid/rpc were wiped on re-run); login timing oracle (Argon2 now always runs, no username enumeration); HTTP login throttle (10/5min per IP, before Argon2 → also CPU-DoS protection); newserver.cookie_secureconfig flag (session cookie; Securewhen behind TLS). - 32.5 Re-verification + follow-ups (THIS SESSION) — 2nd agent run confirmed all 11 fixes as CORRECT/non-bypassable, NO authz regression (every mutating route checked route-by-route); bypass vectors exercised (1-MiB extranonce truncation, channel-kind confusion,
[::ffff:127.0.0.1]/hex-IP/uppercase/trailing-dot SSRF) → all blocked. 3 new small items found + fixed:pooltag_prefixlength validation (≤64 bytes — same silent-block-loss class as Merkle: too long → coinbase scriptSig >100 bytes), Sv2read_bufcap atMAX_NOISE_FRAME(was ~1 MiB/connection pinnable), Merkle regression test vector watertight. - 32.6 Merkle fix live-verified (non-empty mempool) (THIS SESSION) — Closed the blind spot that hid the CRITICAL bug: brought up regtest, wallet + 101 blocks, broadcast 20 transactions into the mempool (getblocktemplate returns
num_txs=20), daemon against regtest, Python Stratum V1 sim-miner. Result: pool builds merkle over 21 leaves (coinbase + 20 tx,merkle_branches=5), sim-miner finds a block (regtest network target trivial) → daemonsubmitting block bytes=6639→ “BLOCK ACCEPTED — Solo won”. Bitcoin Core: chain 101→102, block 102 contains 21 tx, merkleroot accepted, mempool empty afterwards. With the old code, Core would have thrownbad-txnmrklroothere. The fix is confirmed end-to-end against real Core block validation (not just unit-tested). Stack cleanly torn down afterwards (regtest –purge). - 32.7 Sv2 Standard-channel support complete (THIS SESSION) — Closes the functional gap from the audit (Standard channels got
merkle_root=[0u8;32]→ bogus job, and the submit path did not buffer a BlockSolution → no block). Now:JobUpdate/JobSnapshotcarryextranonce_total_size(= V1 extranonce1+2); newpow::standard_extranoncefills the extranonce gap deterministically per channel (4-byte prefix + null padding);build_job_framescomputes the real per-channel merkle root (prefix + standard_extranonce + suffix → txid → merkle_path); newpow_check_standarddoes the full PoW check + buffers aBlockSolution(same daemon submit path as Extended).merkle_rootfield removed fromJobUpdate(was wrong for Standard, ignored for Extended). +3 tests (standard_extranonce helper, computed root in NewMiningJob, Standard BlockFound buffers solution). Remainder: no native Sv2 standard-channel live test (would need a NOISE-capable Sv2 sim-miner; the translator uses Extended) — the coinbase/root math is unit-tested, the submit path is the live-verified shared one. 570 tests green. - 32.8 Signet live test (real public network) + getblocktemplate signet-rule fix (THIS SESSION) — Signet node synchronized (height ~306k), daemon run against it. 🐛 Bug found:
getblocktemplatewas hardcoded with["segwit"]— Signet requires["segwit","signet"]→ RPC error -8, no template (occurs ONLY on Signet, never regtest/main/test). Fix: network-dependent rules viagbt_rules(network)in bitcoin-rpc ("signet"only on Signet, since other networks reject the rule) + unit test;get_block_template(network)signature. Then live-verified: daemon builds jobs over 59–61 REAL Signet mempool tx (incl. Taproot, nbits1d15102a,merkle_branches=6); sim-miner 300 submits → 300 LowDifficulty / 0 errors (validator computes real PoW against real target); soak: 3 job refreshes/min with live mempool tracking (59→61 tx), no panics, stable. Block acceptance with real tx remains proven on regtest (Signet PoW is unreachable for a CPU). 571 tests green. - 32.9 Polish (THIS SESSION) — mDNS log noise dampened (
mdns_sd=offin the default log filter — the ERROR spam “Invalid DNS message” on malformed LAN packets, noticed during the Signet test); Sv2ChannelRegistrycounter overflow-safe (wrapping_addinstead of+= 1, no debug panic on u32 overflow);codec::decode_str_u8now returnsFrameError::InvalidUtf8on UTF-8 error instead of misleadingBadCompactInt; Sv2 connection-cap hot-switch coupled to the admin profile switch (semaphore pulled from the serve loop intoSv2ServerHandle+resize_connection_cap/connection_capanalogous to V1; daemonStratumCapResizernow resizes V1 AND V2 late-bound). +2 tests, 573 green, clippy clean. - Accepted residual risks / minor follow-ups (detail in SECURITY.md): miner-IP disclosure on unauth read endpoints (gate before public exposure); setup-wizard unauth file-read/SSRF on localhost (CSRF/Origin check before non-loopback bind); Web Push DNS-rebinding TOCTOU;
SecretsDebugderive (latent footgun); native Sv2 standard-channel live test (tooling missing); Sv2 extranonce-prefix only per-connection unique, not global (audit INFO; minimal impact for solo — deliberately not churned in the hardened connection path).Sv2 dynamic VarDiff— resolved in v1.12.0: Sv2 channels now run the sameVarDiffengine as V1 (crates/stratum-v2/src/state.rs), including the grace-window ring buffer added in v1.14.1. The channel target is no longer pinned at diff-1.
Phase 31 — Sv2 live test + daemon job-broadcast bug (complete)
- Sv2 + translator live-test setup (THIS SESSION) — Stack: Bitcoin Core regtest + dvb-warppool-daemon with
stratum.sv2_listen=127.0.0.1:3334+secrets.sv2_static_priv_key_hex+ translator sidecar (dvb-warppool-translator, V1-listen :3335 → V2-connect :3334) + Python sim-miner against the translator. Daemon starts cleanly, all listeners up. - 🐛 BUG FOUND: daemon push_job-to-Sv2 missing (THIS SESSION) — The
sv2_handlewas consumed in the daemon setup bysv2_server.serve(sv2_handle), without anyone cloning the handle beforehand to push jobs in. Result: Sv2 server accepts TCP connections + NOISE handshake completely, but never broadcasts a job — translator waits forever for aNewExtendedMiningJobframe and the V1 sim-miner never receives amining.notify. Phase 7.3b notes said “job push is forwarded to the handle below in job_refresh_loop” — but that was never written. Fix:Sv2ServerHandleannotated with#[derive(Clone)](was previously unique-owned); in the daemon setupsv2_handle.clone()into a pushable variable beforeserve();job_refresh_loopsignature extended withOption<Sv2ServerHandle>; afterhandle.push_job(...)(V1) additionallysv2.push_job(JobUpdate {...})for V2 with mapping from V1StratumJob(prev_hash byte-reverse back to BE, coinbase_1/2 → prefix/suffix bytes, merkle_branches → merkle_path, version_rolling_allowed=true). Live-verified after re-build: generatetoaddress 1 → daemon emits “new job” → Sv2 broadcast → translator receives NewExtendedMiningJob+SetNewPrevHash → V1 mining.notify → 20/20 shares accepted. Chain 125 → 126. - Sv2 V1↔V2 translator E2E verified (THIS SESSION) — Full path live: V1 sim-miner (TCP :3335) → translator converts mining.subscribe + mining.authorize to Sv2 SetupConnection + OpenExtendedMiningChannel → NOISE_NX handshake against daemon Sv2 server → SubmitSharesExtended upstream → SubmitSharesSuccess back → V1 OK to sim-miner. 20 accepted / 0 rejected / 0 job rotations (regtest network target allows almost every share as Valid, but not all as BlockFound — probably because the Sv2 path has a different validator config; see follow-up). Meaning: The entire Phase-7 stack has run end-to-end for the first time — the 71 unit tests + 6 translator E2E tests were correct, but the integration in the daemon was missing the 1 push.
Phase 31.7 — Sv2 real PoW check + BlockFound path (complete)
- Sv2 server does real PoW check (THIS SESSION) — Before this fix, the Sv2 server used
AcceptAllValidator→ every share Valid, never BlockFound. The live test (Phase 31) showed: 20 shares accepted, 0 job rotations = no block-found detection. Fix in several parts: (1) Newpowmodule incrates/stratum-v2/src/pow.rswithsha256d/reconstruct_coinbase/compute_merkle_root/build_header/nbits_to_target/hash_meets_target(+ 6 unit tests, sha2 dep new); (2)MiningServerSessiongetscurrent_job: Option<JobSnapshot>, set viarecord_current_jobfrom the server loop after every broadcast (server loop convertsJobUpdate.prev_hash_be→ internal LE per byte-reverse); (3)handle_submit_share_extendeddoes inline PoW check (pow_check_extended): coinbase = prefix+pool_extranonce+miner_extranonce+suffix → txid → merkle_root via path → 80-byte header → sha256d → compare vsnbits_to_target(BlockFound) orchannel.target(Valid/LowDifficulty). Fallback to custom validator when no job is present. - BlockFound event path to daemon submitblock (THIS SESSION) — On BlockFound, the session buffers a
BlockSolution(worker + coinbase_bytes + block_hash_be + ntime/nonce/version). Server loop drains viadrain_block_solutions+ broadcasts via newSv2ServerHandle::block_solution_tx. Daemonblock_submit_loopextended: second select arm for Sv2 solutions, converts viasv2_solution_to_eventto V1BlockFoundEvent(Sv2 coinbase already has extranonce built in = exactly whatbuild_full_block_hexexpects as stripped coinbase). Submit logic extracted into sharedsubmit_found_blockfn (V1 + Sv2 share it). Daemon subscribessubscribe_block_solutions()when Sv2 is active. - Live-verified: BLOCK ACCEPTED via Sv2 (THIS SESSION) — Full stack: V1 sim-miner → translator → NOISE_NX → Sv2 server → PoW check → BlockSolution → daemon → submitblock → “BLOCK ACCEPTED — Solo won”. 6 job rotations = 6 block-founds, chain 125 → 135 (+10 blocks via the Sv2 path). 556 tests green (+6 pow module), clippy
-D warningsclean. Third and last Sv2 live-test bug fixed — the entire Phase-7 stack is now production-functional incl. real block submission.
Phase 31.5 — Sv2 ShareSink + worker-counter bug (complete)
- Sv2 ShareSink trait + wiring (THIS SESSION) — Before this fix, the Sv2 server had no path to persist accepted shares — only the V1 path had a
ShareSink. Result from the live test: 20 Sv2 shares passed through via translator, all accepted, 0 of them inshares_raw/pool_stats. Fix: (a) NewShareSinktrait incrates/stratum-v2/src/server.rsanalogous to V1,Sv2Server::with_sinks(...)constructor; (b)MiningServerSessioncollected accepted-share records (worker + difficulty + was_block) in an internalaccepted_buffer, server loop drains after everyprocess_frameand spawnsshare_sink.record()per entry (non-blocking); (c) Difficulty is computed from the channel target via top-8-byte approximation (sv2_target_to_difficulty); (d) DaemonStorageShareSinkimpls both V1ShareSinkand V2warppool_stratum_v2::server::ShareSinktraits — samerecord_sharecall underneath. - 🐛 BUG FOUND: worker counter generally broken (THIS SESSION) — While implementing the above Sv2 wiring it turned out:
Storage::record_sharewrites only toshares_raw, not to theworkerstable.pool_stats.total_shares_acceptedhowever aggregatesSUM(shares_accepted) FROM workers— i.e. the counter has been permanently 0 since the beginning, also for V1. In the previous live test (Phase 29) this didn’t show because we only checkedtotal_blocks_found, which is updated via separatetouch_worker(_, _, _, 1, _)call in theblock_submit_loop. Fix:record_sharenow additionally callstouch_worker(worker, 1, 0, 0, None)— accepted counter += 1, rejected/blocks stay zero (blocks_delta is still set separately byblock_submit_loop; otherwise it would be double-counted). Live-verified: 20 Sv2 shares →total_shares_accepted: 20,total_workers: 1, workers table shows correct entry. 550 tests green (no test breaks since existing tests usedtouch_workerdirectly, not via the record_share path).
Phase 30 — Setup-wizard Bitcoin-Core install detection (complete)
- 30.1 Backend
/api/bitcoin-install-status(THIS SESSION) — New GET endpoint inapps/dvb-warppool-setup/src/main.rsthat checks: (a)bitcoind(orbitcoind.exeon Windows) in PATH via pure-Rust PATH walk, (b) executebitcoind --versionfirst line if binary found, (c) OS detection viastd::env::consts::OS, (d) Linux distro detection via/etc/os-release(ID + ID_LIKE → debian/fedora/arch/alpine/other). ReturnsBitcoinInstallStatus { binary_path, version_string, os, linux_distro, suggestion }. Pure-fninstall_suggestion_for(os, linux_distro)separately exported for unit tests. - 30.2 UI install card in setup wizard (THIS SESSION) — New card “Bitcoin Core — Installation” directly above the existing Bitcoin Core RPC card in
apps/dvb-warppool-setup/src/index.html. Loads/api/bitcoin-install-statuson page start. Two render paths: (a) if installed: green ✓ marker + version string + binary path + hint “if RPC below fails, check whether bitcoind is running”. (b) if not: yellow ❌ marker + OS detection + copy-paste-ready command in<code>box with copy button (navigator.clipboard) + sudo warning if needed + download link to bitcoincore.org as button + notes list (700 GB storage, 1-3 days IBD, pruning hint). Card getsinstall-card-warnclass when missing → orange border. Operator workflow: copy command → terminal → install → reload page → ✓. - 30.10 Starfield 60 stars in main UI + setup wizard (THIS SESSION) —
ui/src/lib/components/Starfield.svelteupgraded from 15 to 60 stars in 2 drifting layers (80s + 120s reverse, opacity 0.7 for layer 2). Warp-Drive palette retained (white ~60%, plasma-cyan, warp-purple, btc-orange). Visibility tuning from dvb-goPool patched.69 adopted: sizes 1.5-3px (instead of 1-2px), alpha .35-.95 (instead of 0.30-0.80), ~12% “hero” stars with α ≥ 0.70 for Retina/iPhone visibility (sub-pixel AA otherwise washes out small stars). Positions deterministic via Python seed 0xDBC0DE. Setup-wizardindex.htmlwith identical values — layer 2 via.stars::afterpseudo-element (setup wizard has only 1.starsdiv).@media (prefers-reduced-motion: reduce)respected. - 30.9 Setup-wizard starfield stub replaced with dvb-goPool values — rejected in favor of 30.10 — First attempt adopted the dvb-goPool values 1:1 (60 static stars with 4 ambient glow decals + Bitcoin-orange + blue palette). Then on request rejected in favor of 30.10 (main-UI animation + Warp-Drive colors) — the latter fit the brand concept better and are cross-surface consistent.
- 30.8 Wordmark + favicon in setup-wizard header (THIS SESSION) — Wordmark PNG (1×+3× retina) + favicon.svg via
include_bytes!("../../../ui/static/...")compile-time-embedded into the setup binary → self-contained binary. Three new axum routes (/wordmark.png,/wordmark@3x.png,/favicon.svg) with correctContent-Type+ Cache-Control. Header<h1>replaced by<img srcset>analogous to main UI header (78px height, max-width: 80%, cyan drop shadow); mobile-responsive at 56px.<link rel="icon">in<head>. - 30.4 Brand name fixed + server location insertable (THIS SESSION) —
dvb-WarpPoolis no longer operator-configurable in the setup wizard (UI card “Branding” replaced with a pure “Server Location” card, default value"Korschenbroich NRW, Germany"). Backend inapply()deliberately ignores incomingstatus_brand_namefields (let _ = form.status_brand_name;) so older wizard versions or malicious POSTs cannot overwrite the brand.BrandingConfig::default()setsstatus_brand_name = "dvb-WarpPool"as hardcoded brand identity. Live-verified: POST with injectedstatus_brand_name="someone-tries-to-rebrand-this"→ config.toml showsstatus_brand_name = "dvb-WarpPool"(ignored) andserver_location = "Korschenbroich NRW, Germany"(accepted). - 30.5 Submit button only active when required fields filled (THIS SESSION) — Analogous to dvb-goPool:
#submit-btnis initiallydisabled+ greyscale, only becomes active when all required fields are valid. Reactiveinputlistener on 5 fields (payout_address, rpc_url, zmq_hashblock_addr, pool_fee_percent, pool_fee_address). Sub-hint under the button shows either “✓ All required fields filled” (green) or “Required fields missing: …” (orange) with concrete list of missing inputs. Double-check in submit handler prevents anyone from removing thedisabledattribute via DevTools. Format validation: bech32/legacy Bitcoin address, http(s):// URL, tcp://host:port ZMQ. - 30.6 ZMQ hashblock as required field (THIS SESSION) —
zmq_hashblock_addrwas previously optional. Now required so the operator does not accidentally leave it empty and run the pool in the 30s polling fallback (block latency 30s = race against faster pools lost).requiredattribute on<input>+ JS validation checkstcp://host:portformat. ZMQ rawblock remains optional (less used in the current daemon path). - 30.7 Setup-wizard language switcher DE/EN (THIS SESSION) — Vanilla-JS i18n in the embedded
index.html:I18Nobject with ~55 keys × 2 locales (DE as default, EN as fallback). All h2/labels/buttons/hints/notes havedata-i18n="key", placeholders withdata-i18n-placeholder, HTML-permitted withdata-i18n-html. Locale picker top right (🇩🇪 DE / 🇬🇧 EN) with active state. Detection: localStorage > navigator.language (de* → DE, otherwise EN).t(key)fn falls back to EN if key is missing in current locale; if missing in both, the key itself is shown (visible so missing keys are spotted). Dynamic strings (validation hints, install status, copy-button text) uset()directly; install status is re-rendered on locale switch via cachedwindow._lastInstallStatus.<html lang>is automatically set for screenreader correctness. - 30.3 Pure-fn tests (THIS SESSION) — 7 new tests for
install_suggestion_for: macOS (Homebrew), Debian (APT with sudo + old-versions warning), Fedora (DNF), Arch (pacman), unknown-linux-distro (no command, only download link), Windows (winget), unknown-OS (only download link). Subprocess calls (which,bitcoind --version) NOT tested — these are pure system calls without business logic. 550 tests green (+7 vs 543).
Phase 29 — Live test (regtest, local) + submit_block bug (complete)
- Live-test setup (THIS SESSION) —
brew install bitcoin(Core 31.0.0),scripts/regtest-up.shstarts bitcoind-regtest with RPC :28443 + ZMQ hashblock :28332 + cookie auth. Local config in/tmp/warppool-live/{config.toml,secrets.toml}with regtest addressbcrt1qtau…tklgl8mmt, profile=klein (small), min_diff=0.001, ratelimit off. Daemon--releasebuild (10.9 MB) run against the regtest node, UI served by the daemon via--ui-dir. 101 blocks generated viageneratetoaddressto end IBD + mature coinbase. - End-to-end verification (THIS SESSION) — Minimal Python Stratum V1 client (
sim_miner.py) connects to :3333, does subscribe → set_difficulty=0.001 → authorize → mining.notify → 20 mining.submit calls with random nonces. Full path coverage: Bitcoin Core RPC + ZMQ + GBT + Job-Builder + Stratum server + validator + submitblock — all run live. Result over several bursts: chain 101 → 125 (+24 blocks), pool reports 20 blocks_found, all asaccepted: truein/api/blocks(see next point for the bug fix that enabled this). - 🐛 BUG FOUND: submit_block null response (THIS SESSION) — Bitcoin Core’s
submitblockRPC returns"result": nullon successful submit. Thebitcoin-rpcclient incall_once(crates/bitcoin-rpc/src/lib.rs:224) calledenvelope.result.ok_or(MissingField("result"))?, which onOption<Value>deserialize maps both missing-field and value-null toNone→ falseMissingFielderror. Result in the first live run: pool reported “rpc-error” +accepted=falsefor all found blocks, even though Bitcoin Core actually accepted them (chain height grew nonetheless). Fix:unwrap_or(serde_json::Value::Null)instead ofok_or(MissingField)—submit_block’s match arm already correctly implemented theValue::Nullpath. 2 new tests:submit_block_null_response_classified_as_acceptedandsubmit_block_string_response_classified_as_rejected(raw tokio TcpListener as HTTP mock, no extra axum dep). 543 tests green (+2 vs 541). Significance: This bug could have silently broken a mainnet pool — all blocks would have been “found” but appeared as “rejected” in the UI, operator would have spent hours hunting a non-existent RPC problem.
Phase 28 — Eviction policy + cap hot-switch + SSE reconnect (complete)
- 28.1 DB eviction policy (THIS SESSION) — Three DB tables grew unbounded or with hardcoded TTLs to date:
audit_loghadevict_audit_older_thanin storage but was never called in the daemon → real gap since Phase 3.shares_raw/shares_agg_5min/miner_telemetry_raw/miner_telemetry_agg5had evict calls inaggregate_loopbut with literal hardcoded3600/7*24*3600values. NewRetentionConfigin crates/config/src/lib.rs as[retention]block with 5 TTL fields (defaults match the old hardcoded values;audit_log_secsdefault 90 days).aggregate_loopsignature extended withretention, all evict calls now use config values.audit_logeviction only once per hour (last_audit_evict Instant tracker) so the DELETE on a 90d table doesn’t pay in every 60s tick. - 28.2 Connection-cap hot-switch (THIS SESSION) — Profile hot-switch (Phase 3) had left the Stratum semaphore at the old cap size. New
StratumServerHandle::resize_connection_cap(new_cap)in crates/stratum-v1/src/server.rs: increase viasemaphore.add_permits(delta)(immediately visible); decrease via spawnedacquire_many_owned(delta).forget()task that waits until enough permits free up — natural drain without kicking existing connections. Current cap is held in the newcap_tracker: Arc<AtomicUsize>in the handle. NewConnectionCapResizertrait in thewarppool-apicrate (crates/api/src/lib.rs);AppState.connection_cap_resizeras optionalArc<dyn ...>(Nonein tests).post_admin_profilehandler now callsresize(new_profile.connection_cap)and reflects this in the response (newconnection_capfield). Daemon-sideStratumV1CapResizeradapter withOnceLock<StratumServerHandle>for late binding (AppState is built in boot before the Stratum server). - 28.3 SSE auto-reconnect with exponential backoff (THIS SESSION) — ui/src/lib/events.svelte.ts completely re-architected. Before: primitive 5s-fixed timeout that fired only on
readyState=CLOSED, browser auto-retry in parallel = duplicate reconnect attempts. Now: ononerrorthe socket is explicitly closed and a custom reconnect via exponential backoff (BACKOFF_MS = [1s, 2s, 4s, 8s, 16s, 30s]) is scheduled. Counterstate.reconnectAttempts(UI-readable for “reconnecting…” banner display), reset to 0 after successfulonopen. Pure helperbackoffMsForAttempt(attempt)as named export for unit tests + UI display.stop()cancels pending timer. - 28.4 Tests + docs (THIS SESSION) — +7 tests: config (3): retention_defaults_match_historical_hardcoded_values / retention_parses_from_toml_with_custom_values / retention_partial_block_merges_with_defaults; stratum-v1-server (4): handle_connection_cap_returns_initial_max / resize_increase_adds_permits_immediately / resize_same_value_is_noop / resize_decrease_consumes_permits_when_available. Existing
#[cfg(test)]block in stratum-v1::server needed#[allow(clippy::field_reassign_with_default)]analogous to the other test modules (Phase 24 pattern). 541 tests green (+7 vs 534 after Phase 27), svelte-check 298 files / 0 errors / 0 warnings.
Phase 27 — UPnP renew loop (complete)
- 27.1 UpnpConfig + daemon renew loop (THIS SESSION) — Phase 11 implemented UPnP add/remove in the setup binary with default lease 3600s. Without periodic renew, the lease expires after 1h and the pool is no longer reachable from outside. New
[upnp]block in crates/config/src/lib.rs withUpnpConfig { enabled, renew_interval_secs, forwards: Vec<UpnpForwardSpec> }; eachUpnpForwardSpechasport,protocol(tcp/udp),lease_seconds,description+ avalidate()fn that checks port>0, protocol∈{tcp,udp} (case-insensitive), lease∈60..=86400. Daemon-side (apps/dvb-warppool-daemon/src/main.rs):upnp_renew_loopas tokio task; per tickspawn_blocking(run_upnp_renew_once)which callsigd::search_gatewayonce + thenadd_portper spec (idempotent on the router side). Pure-dataUpnpRenewReport { ok, failed, last_error }so the loop and the blocking helper are separately testable.clamp_upnp_intervalclamps to [60s, 24h]. Initial tick 10s after daemon start (crash-recovery case). Errors are only logged — no loop exit on transient errors.igd = "0.12"as new dep in the daemon Cargo.toml (same version as setup binary, no Cargo-lockfile duplicate). - 27.2 Tests + docs (THIS SESSION) — +7 tests: config (3): default-disabled-empty / forward-spec-validate-port-protocol-lease / parses-from-toml-with-multiple-forwards; daemon (4): clamp_upnp_interval_below_60 / above_24h / passes_through_in_range / run_upnp_renew_once_with_empty_specs_returns_empty_report. docs/book/src/health-checks.md Phase-27 section extended (TOML example, lease-vs-interval tip, disable path), Phase-11 “no auto-renew” bullet reworded to “see Phase 27”. 534 Rust tests (+7 vs 527 after Phase 7.7), clippy –all-features -D warnings clean.
Phase 7.7 — Reorg edge cases + stale-share detection (complete)
- 7.7.1 Sv2 server stale-job-id detection (THIS SESSION) —
precheck_submitin stratum-v2/src/session.rs:356 extended with job_id vs channel.current_job_id check. Two new error paths:current_job_id == None(no job broadcast on this channel yet) →"job-not-found"frame;current_job_id == Some(x)butshare.job_id != x(miner submits shares for outdated job after reorg) →"stale-share"frame. Previously, both paths went through to the validator and were judged as LowDifficulty / Valid depending on PoW — semantically wrong, since shares with outdated job_id may not count even with correct PoW. Wiring: New methodMiningServerSession::record_job_broadcast(job_id)called afterbuild_job_frames(server loop inhandle_connection), iterates the ChannelRegistry and setsch.current_job_id. Previously thecurrent_job_idfield was in state but never written. Migration: Existing 3 tests (full_session_setup_open_submit_e2e,duplicate_sequence_number_returns_error,submit_extended_share_returns_success) needed arecord_job_broadcast(0)between OpenChannel and Submit — otherwise they would now correctly get"job-not-found". Translator E2E (end_to_end_v1_miner_through_translator_to_sv2_server) needed an additionalpush_job+ drain of the resultingmining.notifyline beforemining.submit. - 7.7.2 Translator pending_submits bounded cleanup (THIS SESSION) —
pending_submits: HashMap<u32, serde_json::Value>in translator/src/connection.rs:160 was unbounded → DoS vector on pool crash / network split (pool never answers → map grows per submit). New type signatureHashMap<u32, (serde_json::Value, Instant)>with insertion timestamp. Constants:MAX_PENDING_SUBMITS = 1024(covers ~10s burst at 100 shares/s),PENDING_SUBMIT_TTL = 30s(typical pool roundtrip is <1s, 30× safety margin). New helper fnprune_pending_submitsis called before every insert: first TTL pruning (single-pass O(n) retain), then — if still above cap — oldest-by-timestamp eviction. Both remove sites inSubmitSharesSuccess/SubmitSharesErrorhandlers now destructure the tuple. +3 tests:prune_drops_expired_entries,prune_enforces_max_size_by_dropping_oldest,prune_on_empty_map_is_noop. - 7.7.3 +4 Sv2 server stale-detection tests (THIS SESSION) —
submit_before_any_job_broadcast_returns_job_not_found,submit_with_stale_job_id_returns_stale_share,submit_with_matching_job_id_succeeds,record_job_broadcast_updates_all_channels(verifies that both channel kinds — Standard + Extended — get the job_id with the same call). 527 tests green (+7 vs 520 after Phase 25), clippy –all-features -D warnings clean. - Note: The ROADMAP Phase 7.5 note “Standard-channel mining.notify (today the Standard job is emitted locally by the pool, translator uses Extended)” was re-evaluated and is NOT a real bug: Sv2 server already emits
NewMiningJob(0x1E) correctly for Standard channels viabuild_job_frames(stratum-v2/src/server.rs:323); translator usesOpenExtendedMiningChannelby design because Standard channels have no extranonce path — that’s the right architecture, no polish needed.
Phase 25 — Logos/icons + brand polish (complete)
- 25.1 PWA icons in all sizes (THIS SESSION) —
dvb-WarpPool_Logo.png(1024×1024 RGBA with real transparency) as source. Generated via Pillow (not sips — sips serializes alpha channel as white) inui/static/icons/: 16/32/64/180/192/256/384/512 square PNGs with preserved transparency for PWApurpose: any. Additionallyicon-{192,256,384,512}-maskable.pngfor Android Adaptive Icons with 80% safe area + theme-color background#05060B(the logo scales to 80% and centers on a dark square so crops to circle/square/squircle don’t cut off the content).ui/static/apple-touch-icon.png(180×180 as opaque RGB without alpha — iOS renders PNGs with transparency poorly);ui/static/favicon.ico(multi-resolution 16+32+48 for legacy browsers). - 25.2 manifest.webmanifest + app.html updates (THIS SESSION) —
manifest.webmanifesticonsarray extended with 4× any + 2× maskable PNG entries (favicon.svg remains primary for modern browsers with any-sized SVG support).app.htmlnew:<link rel="alternate icon" href=".../favicon.ico">+<link rel="apple-touch-icon">+ 3apple-mobile-web-app-*meta tags (capable=yes, status-bar-style=black-translucent, title=WarpPool); plus 4 OpenGraph tags (og:title/description/image/type) + Twitter card (summary_large_image) for social previews when the pool is publicly linked. - 25.3 Social-preview card (THIS SESSION) — Pillow-generated
docs/social-preview.png+ui/static/og-image.png(1280×640, OpenGraph standard ratio): logo on the left on a manual radial-gradient purple glow (#7B5CFF with alpha falloff), on the right “dvb-WarpPool” as 84pt Helvetica, “Bitcoin Solo Mining Pool” + “Stratum V1 + V2 · PWA · Modern UI” as subtitle, git.warppool.org/dvb-projekt/dvb-WarpPool as footer. Theme-consistent#05060Bbackground. - 25.4 README header with logo (THIS SESSION) —
README.mdgets a centered logo header (<p align="center"><img width=200>) + title as<h1 align="center">+ 1-line tagline + 4 Shields.io badges (License/Rust/Tests/Platforms). HTML tags so GitHub rendering centers the logo correctly (Markdown images are left-aligned). - 25.5 Build verification (THIS SESSION) —
npm run check: 298 files / 0 errors / 0 warnings.npm run build: all 12 PNG icons + apple-touch-icon.png + favicon.ico + og-image.png land inbuild/output (adapter-static copy-from-static). - 25.6 Header typography aligned to logo — rejected (THIS SESSION) — Attempt to render the header title via CSS
background-clip:textwith a vertical Gold→Orange→Bronze gradient was rolled back because the original wordmark of dvb-WarpPool as chrome image asset is significantly higher quality than any CSS approximation (silver-blue-gold with V-shape accent — see 25.7).--gradient-brandCSS var removed fromapp.css,.title+.dotoriginal styles restored. - 25.7 Wordmark as header brand asset (THIS SESSION) — Source
dvb-WarpPool Schriftzug.png(1536×1024 chrome wordmark with silver-dvb / blue-Warp / gold-Pool + V-shape accent + transparent BG) as new brand anchor. Python Pillow pipeline: alpha-threshold bbox finds real content bounds (1029×253) + 24px breathing room →ui/static/wordmark.png(858×240) +wordmark@3x.png(1288×360, retina).+layout.svelte<span class="dot">+<span class="title">replaced by<img class="wordmark" srcset="/wordmark.png 1x, /wordmark@3x.png 3x" width=143 height=40 alt={brand}>. CSS:.wordmark { height: 40px; filter: drop-shadow(0 2px 8px rgba(0,224,255,0.18)); }with hover transition to warp-purple glow. Mobile-responsive: 32px / 28px height steps. - 25.8 Favicon derived from logo (THIS SESSION) — Previous
favicon.svgwas an old abstract purple-cyan design with no logo relation. Newly drawn SVG (ui/static/favicon.svg) adopts the logo DNA: dark#05060Brounded-rect BG, orange Bitcoin rim ellipse (Gold→Orange→Bronze), below it blue-cyan vortex disk, black “wormhole” center, Bitcoin₿in the foreground (oversized 30pt so it stays readable at 16×16) with gold highlight + dark 0.8px stroke. Simplifies the hyper-realistic PNG logo to a flat-design marker that works at any tab size.
Phase 24 — Polish & cleanup (complete)
- 24.1 clippy –all-features clean (THIS SESSION) —
cargo clippy --workspace --all-targets --all-features -- -D warningsgreen. Fixed: 26×field_reassign_with_defaultannotated over test modules with#[allow(...)](6 test modules: config / api / notifier / stratum-v1::ratelimit + ::vardiff / job-builder); 2×manual_ignore_case_cmpin avalon-probe (.eq_ignore_ascii_case); 1×type_complexityin whatsminer-probe (5-tuple →type StatsExtract); 2×get_firstin stratum-v1::messages (.first()instead of.get(0)); 2×match_like_matches_macroin the daemon (debounce checks); 1×explicit_auto_derefin notifier metrics_snapshot; 1×useless_vecin webpush test (&[0u8; 16]); 1×expect_fun_callin telemetry; 1×non_snake_casetest name (cgminer_status_E_yields_error→_e_); 1×doc_lazy_continuationin hwdetect; 1×too_many_argumentsinjob_refresh_loopvia#[allow](central wiring wrapper, 10 args are essential). Benchcrates/job-builder/benches/build_job.rsrewritten directly toMiningConfig { ..Default::default() }pattern (not test code, therefore noallow). - 24.2 Stale TODO/FIXME inventory (THIS SESSION) — 7 TODO sites evaluated: 3× stale in CLI code (Profile hot-switch + NotifierTest were already implemented backend-side in Phase 3/15.5, CLI lagged behind) →
NotifierTestsubcommand re-implemented with realPOST /api/admin/notifier/testcall incl.sink=allspecial path and tabular ok/err output; SetProfile doc comment clarified. 2× in sim-binary stubs (Load/Failures) — replaced by Phase-3scenariosubcommands → clear exit-2 with pointer to existing command instead ofprintln!("TODO"). 1× in job-builder header doc comment (“TODO in share-validator”) → obsolete, Reserved-Value appending is implemented in the daemon (attach_witness_reserved). 1× in sim-node::set_mempool → converted into a doc comment with rationale (the regtest path from Phase 15b is the test path today, SimNode reserved for future mocks).grep -rn "TODO\|FIXME\|XXX"over crates+apps+ui/src now empty. - 24.3 svelte-check & test suite (THIS SESSION) —
cargo test --workspace --all-featuresnow 520 passed / 0 failed / 3 ignored (vs. 514 in memory snapshot, +6 are doc tests that now run too). UI svelte-check: 298 files / 0 errors / 0 warnings (before: 1 pre-existing CSS warningth.numunused in /blocks → resolved by correctly marking the height-column header asclass="num", semantically consistent withtd.num). - 24.4 Mark ROADMAP Phase 2 items as complete (THIS SESSION) — Phase 2 still had 8×
[ ]placeholders despite full realization through Phase 2.1/2.5 + Phase 3. Switched to[x]with reference to the sub-phase in which they were actually implemented.
Phase 23 — Probe hardening (skipped)
- 23 Probe hardening (THIS SESSION skipped) — Probes are LAN-local with latency <100ms;
warppool_miner_probe_health{label,host,vendor}gauge (Phase 22.1) already covers failure detection;last_errorfield in/api/minersshows the reason. Per-vendor timeout + probe-latency histogram analogous to Phase 16.3 would bring marginal value but cost 2-3h of work. Coordinated with the user: skip. Re-eval if probe failures actually pile up in production.
Phase 22 — Vendor probe metrics (complete)
-
22.5 Braiins OS probe adapter (THIS SESSION) — Open-source firmware for Antminer S9/S17/S19 from Braiins (Slush Pool). New
Vendor::BraiinsOSenum variant + parse aliases (“braiins”|“braiins-os”|“bos”|“bos+”|“braiinsos”). mDNS hostname hint in HTTP fallback:braiins*/bos-*prefix maps to BraiinsOS, before the BitMain pattern (so that “braiins-antminer-s19” doesn’t misclassify as stock BitMain). Newprobes/braiins.rswith own field-schema extension:power_consumption_w(Braiins exposes this, stock doesn’t),voltagewith V→mV heuristic (< 100 = V),miner_version/bos_versionas firmware string. Default model “Antminer (Braiins OS)” when STATS has no Type field. Skippedtemp_avg/temp_maxkeys so the hottest chip comes out oftemp1..N. 6 unit tests (typical S19, voltage-in-mv passes through, default-model label, skips-temp-avg/max, string-valued numbers, parse aliases). api/lib.rs match arms for BraiinsOS in 2 places extended. troubleshooting.md vendor table extended. 514 tests green (+6 vs Phase 20.5). -
22.4 AvalonQ probe adapter (THIS SESSION) — Correction of the wrong memory entry “AvalonQ has no API”. AvalonQ exposes the CGMiner socket on 4028 with its own field names (
THSspd=TH/s,TMax/TAvg,Cur_Load=W,Fan1-4,Accepted_Shares,Workmode). Sources:c7ph3r10/ha_avalonqHome Assistant template +gbechtel-beck/avalon-q-controller. NewVendor::AvalonQenum variant +parse("avalonq"|"avalon-q"|"avalon_q")+ display “Avalon Q (Canaan)”. Newprobes/avalonq.rswithAvalonQVendor: CgminerVendor. Hashrate fromTHSspd(×1000 for GH/s) with fallback to SUMMARY fields. TemperatureTMaxpreferred, fallbackTAvg. Power fromCur_Load. Fan maximum fromFan1-Fan4(FanR=percent ignored!). Tolerant of string-valued numbers (some Canaan firmwares send numbers as strings). Default model “Avalon Q” when STATS has no Type field. 6 unit tests (typical-response / tavg-fallback / fanr-not-rpm / string-numbers / default-model / empty-stats). api/lib.rs match arms for AvalonQ in 2 places extended. 464 tests green (+6 vs 22.3). -
22.1 Per-miner Prometheus metrics (THIS SESSION) — Pure-function
miner_telemetry_metrics(&[MinerRecord], now) -> Stringrenders thelast_telemetry_jsonfields from theminerstable as 7 Prom metrics:warppool_miner_{hashrate_ghs,temperature_c,power_w,voltage_mv,fan_rpm}with labels{label,host,vendor,model}+warppool_miner_last_probe_age_seconds+warppool_miner_probe_health(1=OK&recent / 0=error|stale-after-5min). None fields are skipped instead of emitted as 0 so operator trends are not broken. HELP+TYPE per metric exactly once (also for N miners). Label escape for",\,\n. 7 new helper tests + 1 e2e test against the /metrics route. -
22.2 Discovered-miner auto-probing (THIS SESSION) — New
pub type DiscoveredTelemetryCache = Arc<RwLock<HashMap<String, MinerTelemetry>>>in warppool-telemetry.miner_poll_loopsignature extended withdiscovery_cache: Option<DiscoveryCache>,discovered_telemetry: Option<DiscoveredTelemetryCache>,auto_probe_discovered: bool. If envWARPPOOL_AUTO_PROBE_DISCOVERED=true|1|yesis set: per tick additionally probe all mDNS finds, telemetry lands in the shared map (no DB write — discovered hosts are ephemeral).AppState.discovered_telemetry: Option<DiscoveredTelemetryCache>added;/metricsrenders them withlabel="discovered"so PromQL can separate. 2 helper tests + 1 e2e test. -
22.3 Tests + docs (THIS SESSION) —
docs/book/src/observability.mdextended with Phase-22 section with 7-metric table, 4 PromQL examples (sum-hashrate, max-temp, efficiency, probe_health==0), separation discovered vs configured.docs/book/src/configuration-reference.mdextended withWARPPOOL_AUTO_PROBE_DISCOVEREDenv var. 458 tests / 0 failed / 3 ignored (+11 vs Phase 19: 7 unit helper tests + 4 e2e in api).
Phase 19 — Performance benchmarks (complete)
- 19.1 share-validator/benches/validate.rs (THIS SESSION) —
ShareValidator::validate()with 0/8/12 merkle branches plus micro-benches for sha256d (80b header / 500b coinbase), compute_merkle_root (0/4/8/12 depth), reconstruct_coinbase, build_header. Baseline on M-series: validate_full/12=7.59µs (132K shares/s), sha256d_80b=528ns.harness = false+ criterion 0.5 withcargo_bench_supportfeature (no gnuplot/html_reports default). - 19.2 job-builder/benches/build_job.rs (THIS SESSION) —
JobBuilder::build()with 0/100/1000/4000 tx counts + compute_merkle_branches isolated. Baseline: build_job/4000=2.59ms (386 jobs/s), merkle_branches/4000=2.30ms (dominates). - 19.3 stratum-v1/benches/vardiff.rs (THIS SESSION) —
VarDiff::observe_share()hold path (5.2ns/share) and retarget path (~5ns/share avg over 8-share sequence), difficulty_to_target_be (12.85ns), decision_variant_match (432ps). Translator bench deliberately omitted because build_v1_notify is per-job (every 30-60s), not per-share. - 19.4 CI workflow + doc (THIS SESSION) —
.github/workflows/benches.yml— manual-dispatch + tag-push, not PR trigger (Criterion+GitHub-runner noise unreliable); artifactcriterion-reports-<sha>with 30d retention.docs/book/src/benchmarks.mdwith suite overview, baseline tables, interpretation (“Pool CPU load? 0.0076% at 10 shares/s”), regression workflow, what-we-deliberately-didn’t-bench.
Phase 18 — mdBook operator guide expansion (complete)
- 18 Operator pages (THIS SESSION) — Four new mdBook pages:
notifications.md(171 lines) — reference for ntfy/Telegram/Discord/Slack/Email + test workflow.observability.md(207 lines) — Prometheus metrics reference incl. Phase-16 histogram + Grafana JSON + 4 alert recipes.troubleshooting.md(322 lines) — symptom→diagnosis→fix for RPC/IBD/ZMQ/worker-loops/notifier/auto-update/setup/DB-rebuild.configuration-reference.md(291 lines) — complete config.toml + secrets.toml + env vars + CLI override. SUMMARY.md extended; cross-refs in getting-started.md + health-checks.md + auto-update.md. - 18.1 ARCHITECTURE.md + SECURITY.md refresh (THIS SESSION) — The two
{{#include}}stub sources written before the Sv2 stack + Phase-3 auth + Phase-15 notifier were completely rewritten. ARCHITECTURE.md (98→401 lines): 16 crates in 5 categories, daemon task topology, shared-state table, storage-schema table (10 tables), Sv2 stack detail, connection-lifecycle hook pattern, event bus + SSE list, boot lifecycle in 21 steps. SECURITY.md (33→279 lines): 4-actor threat model, 19-row threat-mitigation matrix with phase references, auth-stack walkthrough (JWT/API tokens/2FA-TOTP), key-material table with leak consequences, audit-log inventory, TLS layers, reproducible builds + Cosign workflow, Stratum hardening, “what is deliberately not implemented”.
Phase 16 — Observability extension (complete)
- 16.1 Notifier counter (THIS SESSION) —
Notifierstruct getscounters: tokio::sync::Mutex<HashMap<(sink, event_kind, result), u64>>.inc_countercalled fromnotify()+test_sinks().metrics_snapshot()returnsVec<NotifierMetric>for /metrics handler. Per-sink view on send successes/failures. - 16.2 Worker lifecycle metrics (THIS SESSION) — New
crates/telemetry/src/metrics.rswithPoolMetricsstruct:AtomicU64/AtomicI64counters forworkers_authorized_total,workers_disconnected_total,active_connections_{v1,v2}.NotifierConnectionSinkgetsmetrics: Option<Arc<PoolMetrics>>+protocol: &'static str(“v1”|“v2”), increments per on_authorized/on_disconnect. - 16.3 RPC latency histogram (THIS SESSION) —
BitcoinRpc.metrics: Option<Arc<PoolMetrics>>+ builderwith_metrics(arc).BitcoinRpc::callwrapped inInstant::now()measurement for total elapsed time (incl. all retries) →pool_metrics.record_rpc_latency(secs). Buckets[0.001, 0.005, 0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, +Inf](Prometheus cumulative semantics). Innercall_with_retryis the old logic moved. - /metrics handler in
warppool-apiextended with 6 new Prom metrics (when pool_metrics/notifier set):warppool_workers_authorized_total(counter),warppool_workers_disconnected_total(counter),warppool_active_connections{protocol="v1|v2"}(gauge),warppool_bitcoin_rpc_latency_seconds(histogram),warppool_notifier_events_sent_total{sink,event,result}(counter),warppool_notifier_sinks_active(gauge). Opt-out by default: without pool_metrics, /metrics renders only the base counter. - AppState gets
pool_metrics: Option<Arc<PoolMetrics>>field, default None in test builders. +8 tests (5 telemetry + 3 api). 447/447 tests green.
Phase 15.2b — v2 connection notifier wiring (complete)
- 15.2b Sv2 connection-lifecycle hooks (THIS SESSION) — Analogous to Phase 15.2 for stratum-v2. New
pub trait ConnectionSinkincrates/stratum-v2/src/session.rswith signature identical to v1.Sv2ServerConfig.connection_sink: Option<SharedConnectionSink>.Sv2Server::with_connection_sink(...)as new ctor. handle_connection wraps the loop inasync {...}.awaitso on_disconnect fires on both paths (Ok-exit + Err-exit).notified_users: HashSet<String>tracks seenuser_identitystrings; after eachsession.process_frame, iteratesession.channels(), every NEW user_identity fireson_authorized(spawned). On loop exit:on_disconnectfor every seen user. A v2 connection can open multiple channels with different identities — all are correctly notified. Daemon builds two NotifierConnectionSink instances (v1 + v2) each with the same notifier + debounce setting. NotifierConnectionSink impl both traits (v1+v2) on the same struct via separate impl blocks. Sharedhandle_disconnectmethod factors out the debounce+notify logic. async-trait added as new dependency in stratum-v2. 71/71 v2 tests green, all existing tests green.
Phase 15 — Notifier fully wired + Email/Slack + Admin UI (complete)
- 15.1 RpcDown/Recovered in the daemon (THIS SESSION) — Health loop in daemon main.rs tracks
prev_rpc_ok: Option<bool>+down_since: Option<Instant>; on (Some(true)|None, false) fireNotifyEvent::RpcDown {duration_secs:0}, on (Some(false), true) fireRpcRecoveredwithdown_since.elapsed().as_secs()as debug log. Inline 4-arm match. - 15.2 MinerDisconnect (V1) (THIS SESSION) — New
ConnectionSinktrait inwarppool-stratum-v1analogous toShareSink:on_authorized(worker, peer)+on_disconnect(worker, peer).SessionRunArgsgetsconnection_sink: Option<SharedConnectionSink>,Session::runfires hooks in spawned task. Daemon adapterNotifierConnectionSink {notifier, debounce, last_notified: Mutex<HashMap<String,Instant>>}with env-configurableWARPPOOL_DISCONNECT_DEBOUNCE_SECS(default 30). - 15.3 Email sink (THIS SESSION) —
lettre = "0.11"workspace dep withtokio1-rustls-tls + smtp-transport + builder + hostname + ringfeatures (rustls instead of openssl-sys).EmailSinkparsessmtps://user@host:465(implicit TLS) orsmtp://user@host:587(STARTTLS); auth viapassword_envconfig field. Extended withon_miner_disconnect+on_rpc_downtoggles. 7 new tests. - 15.4 Slack sink (THIS SESSION) — Webhook with Block Kit payload (header + section with mrkdwn). Per-event emoji. 4 new tests.
- 15.5 API endpoints (THIS SESSION) —
AppState.notifier: Option<Arc<Notifier>>. NewNotifier::test_sinks(filter) -> Vec<SinkTestResult>: 1 attempt (no retry backoff). 2 new routes:GET /api/admin/notifier/sinks+POST /api/admin/notifier/test[?sink=<name>]. Audit lognotifier.test. 6 new API tests. - 15.6 UI admin section (THIS SESSION) —
src/lib/api.tsadmin.notifier.{sinks, test}client methods. New BentoCard “Server-Side Sinks (Daemon)” with per-item test buttons + badge state (neutral/ok/err) + last-error tooltip + “Test all sinks” primary. svelte-check 0 errors, 1 pre-existing CSS warning unchanged. - 15b Regtest E2E scaffold (THIS SESSION) —
scripts/regtest-up.sh(bitcoind regtest with RPC+ZMQ-hashblock+ZMQ-rawtx, configurable via env vars, idempotent via PID file, KEY=value env output foreval) +scripts/regtest-down.sh(clean stop + optional--purge).crates/bitcoin-rpc/tests/regtest_e2e.rswith 3#[ignore]tests (regtest_blockchain_info_returns_regtest_chain, getblocktemplate_works, submit_invalid_block_is_rejected). Locally:eval "$(scripts/regtest-up.sh)"; cargo test ... -- --ignored; scripts/regtest-down.sh --purge. 439/439 Rust tests + 3 ignored.
Phase 14 — UI HealthBanner (complete)
- 14 SSE events → dashboard banner (THIS SESSION) — Closes the backend-push story from Phase 8e (
update_available) + 13b (health_snapshot): until now the daemon pushed the events, the UI ignored them. Now a newHealthBanner.sveltecomponent renders them as a persistent banner below the header (visible on all pages via +layout.svelte). TypeScript types (ui/src/lib/types.ts):HealthSnapshotEvent+UpdateAvailableEventinterfaces, extended into PoolEvent union.PoolEventTypederived type automatically gets both. events.svelte.tsALL_TYPESarray extended with ‘update_available’ + ‘health_snapshot’ so EventSource registers the named events. Component behavior:lastHealth+lastUpdatestate, both separately dismissable per session (no localStorage — after tab reload they come back).maybeRevivehelper: if new warnings appear that weren’t there before, dismissed flag is reset (operator sees state deterioration). Update banner: dismissed flag reset on new latest tag. Health banner: yellow-orange border + ⚠️ icon, lists warnings as bullet list + meta row (peers/zmq/ibd). Update banner: cyan-magenta gradient + ⬆️ icon, current→latest, pre-release badge (red) if prerelease=true, link to /admin. i18n keys in all 8 locales (de/en/es/fr/it/ja/pt-BR/zh) for healthBanner.* + updateBanner.* (en/de hand-crafted, remaining 6 with English fallback via Python script). svelte-check 294 files / 0 errors / 1 pre-existing warning (CSS unused selector in blocks/+page, not my code). 423/423 Rust tests green (no Rust-side change in 14). ROADMAP “Phase 14 — UI HealthBanner” section.
Phase 13b — Daemon periodic health (complete)
- 13b Periodic re-check (THIS SESSION) — Daemon uses
warppool-healthcrate (Phase 13a) for continuous Bitcoin Core diagnostics in the running pool. env varWARPPOOL_HEALTH_CHECK_INTERVAL_SECONDS(default 60, 0=off) gates the tokio task. Per tick: RPC auth re-resolved from config.node.rpc_cookie_path preferred + fallback to secrets.rpc_user/pass (cookie is rotated on Bitcoin restart, so always fresh each time);check_bitcoin_health+collect_bitcoin_warnings; publish newPoolEvent::HealthSnapshot{at, rpc_ok, peers_total, peers_inbound, ibd, pruned, zmq_hashblock_ok, warnings[]}variant in event_bus. Respects cancellation token. 2 new tests (health_snapshot serialize with snake_case tag + warnings array; round_trips_through_bus). event_type_name helper extended with “health_snapshot” variant. 423/423 tests green (+2 vs 13a). docs/book/src/health-checks.md Phase-13 section with wiring description, SSE event format JSON example, what-NOT-13b (no bitcoin.conf parse, no UPnP, no delta detection, no /api/admin/health endpoint), operator curl example for live watch, Phase-14 outlook (UI dashboard widget, delta detection, REST endpoint).
Phase 13a — warppool-health crate (complete)
- 13a Crate extraction (THIS SESSION) — Pure refactor. New crate
crates/health/with 3 modules:rpc.rs(RpcAuth + RpcError + rpc_call + resolve_rpc_auth + 4 tests),bitcoin.rs(BitcoinHealth + PeerStats + ZmqEndpoints + check_bitcoin_health async + collect_bitcoin_warnings pure-fn + 7 tests),conf.rs(ParsedConf + parse_bitcoin_conf_str + generate_bitcoinconf_snippet + 13 tests incl. parser edge cases + filter logic). Setup wizard refactored to a thin axum handler wrapper (~60-linebitcoin_health()instead of ~300+ lines previously) that calls into the crate + wraps setup-specific tilde-expand in front of it. Setup wizard loses 25 health tests that now live in the crate (cleanly consolidated; 1 test was redundant — net -1 vs 422). Foundation for Phase 13b (daemon periodic-health-check task) which uses the crate without code duplication. 421/421 tests green. clippy clean (1 pre-existingio::Error::new(Other,...)→io::Error::other(...)modernization fixed).
Phase 12 — bitcoin.conf parse-existing (complete)
- 12 parse + filter (THIS SESSION) —
parse_bitcoin_conf_str(&str) -> BTreeMap<String, Vec<String>>as line-based tolerant parser (comments, empty lines, section headers[main]/[test]ignored, multiple values per key, malformed lines tolerant).BitcoinHealthReqgets optionalbitcoin_conf_path: String.bitcoin_health()handler reads the file when path is set; if parse result is empty (file not readable) → warning but health check continues.generate_bitcoinconf_snippet(&BitcoinHealth, Option<&ParsedConf>)filters recommendations per key (already_has("zmqpubhashblock") → skip). Default behavior remains: without path arg → all recommendations as before. UI in the Bitcoin card: optional input field “bitcoin.conf path (optional, for smarter snippets)” with OS-aware placeholder + hint text. Path is sent with /api/bitcoin-health. 10 new tests (parser: empty+comments, basic, multiple-values, section-headers, whitespace, malformed-lines; filter: skip-zmq-when-configured, skip-listen, skip-maxconnections, no-filter-when-no-parsed). 422/422 tests green (+10 vs 11). docs/book/src/health-checks.md Phase-12 section with workflow, before/after example, what-we-don’t-do list (no value compare, no direct write, no TOML parse), Phase-13 outlook.
Phase 11 — UPnP port forwarding (complete)
- 11 UPnP add/remove via igd (THIS SESSION) — POST /api/network-upnp-forward + POST /api/network-upnp-remove. Port whitelist {8333, 3333, 3334, 34254} so it can’t be misused as a generic port opener. Protocol whitelist {tcp, udp}. Consent gate → 422 without
consent=true(router state change is invasive). Default lease 3600s (1h), hard cap 86400s (24h). Local IPv4 detection via UDP-connect trick (bind 0.0.0.0:0 + connect 8.8.8.8:80 + local_addr) — no extra dep. IPv6 outbound → error (UPnP requires IPv4 NAT). spawn_blocking because igd is sync. UI in the Network card: per Stratum port + Bitcoin P2P, one Forward+Remove button + global consent toggle. Success feedback shows local_ip + lease_seconds + gateway. 8 new tests (consent-422, wrong-port-400, wrong-protocol-400, remove without consent, remove wrong-port, allowed-ports-list-exact, default-lease-1h, parse_protocol case-insensitive). 412/412 tests green (+8 vs 10a). Live tested against FritzBox: discover OK, add_port refused with “client not authorized” (FritzBox default — UPnP state changes must be separately enabled); backend responds cleanly with HTTP 502 + clear error message. docs/book/src/health-checks.md extended with Phase-11 section: endpoints, security-gates table, what-we-don’t-do list, typical router quirks (FritzBox/OpenWrt/Telekom-Speedport), Phase-12 outlook (periodic re-check, bitcoin.conf parse-existing, UPnP renew loop).
Phase 10 — bitcoin.conf snippet generator (complete)
- 10a Snippet generator (THIS SESSION) — Pure function
generate_bitcoinconf_snippet(&BitcoinHealth) -> Option<String>maps warnings to concrete bitcoin.conf lines: ZMQ-missing→pubhashblock+pubrawblock, low-peers→maxconnections=125, no-inbound-mainnet→listen=1. Wrapped in# ===== dvb-WarpPool Recommendations =====header/footer so the operator copy-pastes without shadowing existing settings.BitcoinHealthgetsrecommended_conf_snippet: Option<String>field. UI: 📋 box with copy button (navigator.clipboard.writeText) + OS-aware path hint (Linux/macOS/Windows via navigator.platform) + fallback alert when clipboard API is not available. 6 unit tests (healthy-no-snippet, zmq-missing, rawblock-existing-only-hashblock, listen-mainnet-not-testnet, maxconnections-trigger, 8-outbound-no-maxconnections). 404/404 tests green (+6 vs 9). docs/book/src/health-checks.md extended with snippet section + Phase-11 outlook (periodic re-check in the daemon, bitcoin.conf parse-existing for missing-lines-only, UPnP port mapping).
Phase 9 — Setup health checks (complete)
- 9a Bitcoin multi-RPC health check (THIS SESSION) — 5 RPC calls (getnetworkinfo, getblockchaininfo, getindexinfo, getpeerinfo, getzmqnotifications) in parallel via tokio::join!. Aggregated in BitcoinHealth struct with pure
collect_bitcoin_warnings()helper. Warnings: IBD in progress, low peers, no inbound (only mainnet), no zmq-hashblock, pruned, low verification progress (excl. when IBD). 8 unit tests for all edge cases. - 9b Local network setup (THIS SESSION) — Port-bind smoke for 3333/3334/34254 (TcpListener::bind+drop), UPnP discovery via
igdcrate (pure-rust) in spawn_blocking wrapper. Returns NetworkHealthLocal with ports[] + upnp{gateway, external_ip} + warnings. Live tested against FritzBox. - 9b External probes with consent (THIS SESSION) — POST /api/external-probe with body {probe, consent, public_ip?}. 422 if consent != true. Three probe types: ip_echo (api.ipify.org), bitnodes_8333 (bitnodes.io/api), stratum_port_guide (returns only 3rd-party tool URLs, contacts NOTHING itself).
contactedfield in response for audit transparency. - 9 UI (THIS SESSION) — Health card with RPC call result + warnings list; Network card with port status + UPnP + 3 separately-consented probe rows (checkbox + inline explanation + red 🛡 warn badge). Caching of external IP between UPnP/IP-echo → bitnodes probe without extra server call.
- 9 Tests — 10 new tests (8 warnings logic + consent gate + unknown probe rejection + stratum-guide no-http-calls). 398/398 tests green (+10 vs 8g).
- Docs — New mdBook chapter
health-checks.mdwith privacy matrix + warnings explanation + Phase-10 outlook (periodic re-check in the daemon, bitcoin.conf auto-write, UPnP port mapping).
Phase 8 — Polishing (complete 8a-8g)
- 8b Reproducible builds (THIS SESSION) — Cargo
[profile.release]switched tolto = "fat"+ explicitincremental = falsefor deterministic LTO without thin-parallelism drift. New .github/workflows/repro.yml buildsdvb-warppool-daemonin two matrix jobs (a/b) on two fresh Linux x86_64 runners with identicalSOURCE_DATE_EPOCHfrom commit timestamp andRUSTFLAGS=--remap-path-prefix=<workspace>=/repo + cargo-home=/cargo/registry + cargo-git=/cargo/git; diff job compares sha256, fails on drift. scripts/verify-reproducible.sh as end-user verify tool: builds locally with same flags + downloads release asset from GitHub + sha256 compare. New mdBook chapter docs/book/src/reproducible.md with Why/How/Limitations/Debug-Workflow. SLSA-3 provenance viaslsa-github-generatorhas been in release.yml since Phase 6 — this 8b validates that the build is deterministic (which SLSA provenance does NOT guarantee, only the origin). - Cosign signatures — shipped. Started in Phase 6 as keyless OIDC
cosign sign-blob; since the forge move every release is signed with the project cosign key (cosign.pubin the repo root,SHA256SUMS.bundle). Keyless-OIDC artifacts exist only for releases ≤ v1.15.0. - SBOM (Syft) — shipped,
sbom.spdx.jsonin every release viaanchore/sbom-action. - 8c Auto-update foundation (THIS SESSION) — New crate
warppool-autoupdatewith 4 modules:version(Semver-subset parser incl. pre-release ordering),release(minimal GitHub API client via reqwest+serde, no octocrab),download(streaming download with SHA-256 verify; mismatch → file is deleted),swap(atomic_swap(new, current, backup_to)via POSIX rename + chmod 0755 + optional backup slot). Two new CLI subcommands indvb-warppool-cli:check-update [--repo X](HTTP fetch + version compare against CARGO_PKG_VERSION + asset list;--jsonfor scripting),download-update --sha256 X --to PATH [--asset Y](auto-asset selection viahost_asset_substring()for linux-x86_64/-aarch64/macos-x86_64/-aarch64/windows-x86_64, otherwise manual--assetoverride; no atomic swap — operator does it manually for auditability). mdBook chapter auto-update.md with MVP flow + operator bash script example incl. cosign verify + Phase-8d outlook (atomic swap on running daemon + rollback logic + integrated cosign). +25 tests (24 lib + 1 doctest in autoupdate). 381/381 tests green (vs 356 before 8c). - 8d Auto-update API wiring (THIS SESSION) — AppState gets
update_checker: Option<Arc<UpdateChecker>>. Two new admin-protected endpoints inwarppool-api:GET /api/admin/update-check(HTTP fetch latest + compare against CARGO_PKG_VERSION; response incl. assets + newer flag; auditupdate.check),POST /api/admin/update {asset, sha256, target_path, backup_path?}(download_verified + atomic_swap from inside the daemon process; no restart —restart_hintfield showssystemctl restart dvb-warppool; auditupdate.applied/update.failedwith details). Both 503 when checker=None. Daemon readsWARPPOOL_AUTOUPDATE_REPOenv var on startup; set →Arc<UpdateChecker>injected, otherwise None (deliberately not in config.toml because shell-access equivalent).UpdateChecker::with_api_base()builder new for tests against a local mock server. 4 new API tests:update_check_without_checker_returns_503,update_check_without_auth_returns_401,update_check_returns_latest_and_newer(against axum mock GitHub server),update_apply_without_checker_returns_503. 385/385 tests green (+4 vs 8c). docs/book/src/auto-update.md extended with Phase-8d API section incl. curl examples + systemd activation. Phase-8e as outlook (automatic restart via systemd D-Bus, OnFailure rollback, integrated cosign, periodic SSEupdate_availableevent). - 8e Periodic auto-check + SSE event (THIS SESSION) — New PoolEvent variant
UpdateAvailable{at, current, latest, name, prerelease}with snake_case type tagupdate_available. Daemon spawns periodic background task whenWARPPOOL_AUTOUPDATE_REPOANDWARPPOOL_AUTOUPDATE_INTERVAL_HOURS > 0(default 24, 0=disabled): every N hoursfetch_latest+is_newer_than(CARGO_PKG_VERSION)→ on newer releasebus.publish(UpdateAvailable). Initial delay 60s against GitHub API burst on boot. API errors (rate limit, network) → warn log, retry in the next interval (24h interval = ~2 req/day, far below the 60-req/h anonymous limit). 2 new tests (update_available_serializes_with_snake_case_tag,update_available_round_trips_through_bus). docs/book/src/auto-update.md extended with Phase-8e section incl. SSE event schema and JS EventSource example for UI banner. - 8f Systemd OnFailure rollback (THIS SESSION) —
dvb-warppool.serviceextended withStartLimitInterval=300s+StartLimitBurst=4+OnFailure=dvb-warppool-rollback.service(otherwise Restart=on-failure would loop endlessly without triggering OnFailure). New files:packaging/systemd/dvb-warppool-rollback.service(Type=oneshot, User=root, ConditionPathExists for/usr/lib/dvb-warppool/rollback.sh, no restart loop) +packaging/systemd/rollback.sh(portable bash, BSD+GNU compatible — usesls -1tinstead of GNU-onlyfind -printf): finds newest backup in$WARPPOOL_BACKUP_DIR(default /var/lib/dvb-warppool/backup), atomic install viainstall -m 755+mv -f(chown implicit since running as root), moves consumed backup to.applied-<timestamp>(prevents restart loop),systemctl restart --no-blockso no deadlock. Idempotent: no backup → warn + exit 0 (OnFailure chain doesn’t spin). Override vars: WARPPOOL_BACKUP_DIR / WARPPOOL_TARGET_BIN / WARPPOOL_SERVICE. Mock-tested locally (mock-systemctl in PATH + tmp dirs for backup/target): newest backup wins, target gets replaced, backup gets renamed, systemctl called with restart. Missing-backup path also tested (exit 0 + warn log). docs/book/src/auto-update.md extended with Phase-8f section incl. flow diagram + configuration + local test snippet. - 8g Cosign verify integrated (THIS SESSION) — External cosign CLI invocation as tokio::process::Command before sha256 verify in POST /api/admin/update. Body extended with
cosign_verify: bool+cosign_args: Vec<String>(operator-controlled, server only appends the downloaded file as the last arg). Env varWARPPOOL_COSIGN_BINmust be set whencosign_verify=true— otherwise 500 (NOT silent skip, so the operator doesn’t get false security). Cosign exit≠0 → 403 + auditupdate.failed/cosign verify-blob failed: exit N+ downloaded file is deleted. Rationale for subprocess instead of pure-rust sigstore: 30+ transitive deps saved, transparent (operator sees the exact cosign command), current (sigstore updates separately). 1 new test (update_apply_with_cosign_verify_but_no_env_returns_500) — fired against a real axum mock-asset server + mock GitHub API, env var explicitly cleared so deterministic. 388/388 tests green (+1 vs 8f). docs/book/src/auto-update.md Phase-8g section with complete operator example incl. real GitHub release asset URLs + sigstore OIDC issuer + identity regex. - 8a Documentation site (THIS SESSION) — mdBook under docs/book/ consolidates all existing Markdown files (ARCHITECTURE / PACKAGING / SECURITY / TESTING / UI-DESIGN / ROADMAP) via
{{#include}}directive plus three newly written chapters: Introduction (project pitch + status table), Getting Started (Docker / Native / Source build paths + Stratum V1/V2 listener overview + first-block walkthrough), Phase history (curated narrative for Sv2 7.1 → 7.6a with test counts and implementation details). CI: .github/workflows/docs.yml installs mdbook release binary (cached between runs), builds on push-to-main + tags + manual-dispatch, deploys viaactions/deploy-pages@v4to github-pages. PR builds run smoke check (index.html + roadmap.html + phases.html exist) without deploy.docs/book/out/in.gitignore(build artifact, never committed). Smoke test locally:mdbook v0.4.40 build docs/bookruns cleanly, all 9 includes resolve.