Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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-apps submission.
  • 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 .dmg in CI — likewise no macOS runner; the .dmg is built by hand per release via packaging/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-profiles crate (Small/Medium/Large/Enterprise) — implemented + tested
  • warppool-config crate (schema port from dvb-gopool) — implemented + tested
  • warppool-hwdetect crate — hardware auto-detection + profile recommendation
  • Coinbase splits (pool fee + donation, default “No Pool Fee”, relevant for Large/Enterprise / multi-user solo)
  • warppool-simulator crate (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-simsolo-block-found scenario 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-storagevardiff_state table + load/save
  • Session integration — load on Authorize, observe on Accept, retarget with set_difficulty + pool-target update, async save
  • [vardiff] config section + StorageVarDiffStore adapter 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-Rust zeromq crate, 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_loop select arm. Polling remains as fallback.
  • --no-zmq CLI 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) — Session generic over AsyncRead+AsyncWrite, tls::load_tls_config (rustls + aws-lc-rs), StratumServer::serve_tls_with_listener with tokio-rustls::TlsAcceptor, daemon optionally spawns a parallel TLS listener when stratum_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 the accept loop before the semaphore check, in handle_authorize before 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-secret subcommands for setup. When admin_password_hash or jwt_secret in 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. --json global flag for scripting. Pretty-print with humanized hashrate units (H/s → EH/s).
  • Sim-Runtime: solo-block-found for real (THIS SESSION) — JsonStratumMiner as a real TCP Stratum V1 client, persona-driven submit rate with Poisson jitter + 50ms cap. dvb-warppool-sim scenario solo-block-found --duration N spawns 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 N spawns 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 N spawns N parallel JsonStratumMiner with 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_tokens table with SHA-256 hash (plaintext never persisted), format wpat_<32hex>. mint_api_token() + sha256_hex() in api/auth. AuthIdentity extractor distinguishes wpat_ prefix → DB lookup via find_active_token_by_hash; otherwise continues with JWT. Routes: POST/GET/DELETE /api/admin/tokens (with audit log). Soft-delete via revoked_at. CLI subcommands token-create -n -- --ttl --scope / token-list / token-revoke <id>.
  • Auth Subphase 3 — 2FA-TOTP (THIS SESSION) — admin_2fa table 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 accepts totp_code; required when 2FA is active. 401 with requires_2fa: true flag when code is missing. CLI subcommands twofa-status/setup/enable/disable, login --totp CODE. Phase 3 complete with this.
  • Profile hot-switch + X-Forwarded-For (THIS SESSION) — pool_settings KV table, AppState.profile_kind is now Arc<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 from X-Forwarded-For header (first element). Protects against IP spoofing on direct internet access.
  • Tiered retention + aggregation worker (THIS SESSION) — shares_raw + shares_agg_5min tables, record_share + aggregate_5min + evict methods in storage. ShareSink trait in stratum-v1 (Session calls sink.record after Valid/BlockFound in a spawned task). StorageShareSink adapter 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_log table (id/at/actor/action/target/peer_ip/ok/details). AuditSink trait in warppool-api::auth + StorageAuditSink adapter in the daemon. Auth routes fire login.ok / login.fail. Protected endpoint GET /api/admin/audit?limit=N&actor=.... Peer-IP via optional ConnectInfo<SocketAddr> (daemon uses into_make_service_with_connect_info::<SocketAddr>). CLI audit --limit --actor subcommand 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.ts with Svelte 5 runes ($state in .svelte.ts module), cookie-based session (credentials:‘include’ on all fetches), localStorage holds only username + marker (token is HttpOnly cookie). /login route with username/password + optional TOTP code (appears after requires_2fa: true response). 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.svelte with auth guard (redirect /login if unauth). Header shows “Admin” link only when authed. lib/api.ts admin.* sub-namespace with all endpoints.
  • UI Subphase 3: Hashrate chart (with /api/hashrate, HashrateChart.svelte)
  • Live updates via SSE (/api/eventslib/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 daemon push_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 .dmg is built by hand via packaging/macos/build-macos-release.sh and 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 Foundationwarppool-stratum-v2 crate: codec (binary frame with compact_int), messages (SetupConnection, OpenStandardMiningChannel, SubmitSharesStandard, +Success/Error), noise (NOISE_NX_25519_ChaChaPoly_BLAKE2s via snow)
  • 7.2 State machineMiningServerSession with AwaitSetup → MiningProtocol → Closed phases, ChannelRegistry with per-channel extranonce prefix + duplicate-sequence detection
  • 7.3a TCP/NOISE serverSv2Server with 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 (OpenExtendedMiningChannel 0x13, OpenExtendedMiningChannelSuccess 0x14, SubmitSharesExtended 0x1F, OpenMiningChannelError 0x12, SubmitShares.Success 0x1C, SubmitShares.Error 0x1D) including server handler with real Success/Error responses (old Phase-7.3 TODOs resolved). Public Sv2Client (TCP+NOISE initiator, previously cfg(test) only). New crate warppool-translator + binary dvb-warppool-translator (clap CLI). Per V1 connection the translator opens a V2 connection, buffers mining.subscribe until mining.authorize arrives, then sends OpenExtendedMiningChannel upstream. mining.submitSubmitSharesExtended with 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_hash byte order in V1 mining.notify is 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.configure handler 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 reports version_rolling_allowed=true, otherwise 0. With agreed=0 → response {"version-rolling": false}. mining.submit parses optional 6th param version_bits_hex; with rolling active, the translator builds V2 SubmitSharesExtended.version = (job.version & !mask) | (miner_bits & mask) (BIP-320 XOR). Previously version: 0 was 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: NewMiningJob 0x1E (for standard channels, with finished merkle_root), NewExtendedMiningJob 0x22 (for extended, with version_rolling_allowed + SEQ0_255<B032> merkle_path + B0_64K coinbase_prefix/suffix), SetNewPrevHash 0x20 (channel_id/job_id/prev_hash/min_ntime/nbits), SetTarget 0x21 (channel_id/maximum_target). Sv2 server fan-out: JobUpdate extended 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-up SetNewPrevHash for each open channel (Standard → NewMiningJob+SNPH, Extended → NewExtendedMiningJob+SNPH); previously “log only”. Translator learns NewExtendedMiningJob + SetNewPrevHash + SetTarget; caches both job halves, pairs them by job_id, maybe_emit_notify() sends V1 mining.notify with all 9 params (job_id/prev_hash_hex/cb1/cb2/merkle_branches/version_BE/nbits_BE/ntime_BE/clean_jobs); clean_jobs=true on 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): CoinbaseOutputDataSize 0x70 (Pool→Node), NewTemplate 0x71 (Node→Pool, with future_template bit for pre-build, coinbase_prefix/outputs B0_64K, SEQ0_255 merkle_path), SetNewPrevHashTdp 0x72 (note: separate wire format from Mining 0x20! template_id u64 + prev_hash + header_timestamp + nbits + target), RequestTransactionData 0x73 (Pool→Node), RequestTransactionData.Success 0x74 (with SEQ0_64K<B0_16M> transaction_list — u16 count + each tx u24-LE-prefixed), RequestTransactionData.Error 0x75, SubmitSolution 0x76 (Pool→Node with finished coinbase). Session state machine: new phase SessionPhase::TemplateDistribution, handle_setup accepts Protocol::TemplateDistributionProtocol (=2) and switches to the phase. TDP frames are accepted in the phase and acknowledged with tracing::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 Wiringshipped 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 with bitcoin-sv2-tp packaging (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 the mining.capnp schema (clients built against older schemas fail hard on Init.makeMining; createNewBlock/checkBlock gained a required context parameter, getCoinbaseTx changed 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 into stratum libs + sv2-apps binaries/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-in warppool-bitcoin-ipc template source (Cap’n Proto against mainline Core >= 31) now feeds the same job_refresh_loop/JobBuilder path through a shared TemplateSource trait; 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::webpush module with pure-Rust crypto stack (p256 for ECDH/ECDSA, ece v2 for AES-128-GCM Content-Encoding RFC 8188, jsonwebtoken for 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 subcommand gen-vapid-keys prints a TOML snippet to stdout (+ pub-key to stderr for UI hint). secrets.toml fields vapid_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’s PushManager.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 with vapid_public_key + web_push_sender: Option<Arc<WebPushSender>>.
  • 21.3 Daemon push-send loop (THIS SESSION) — push_send_loop subscribes to PoolEventBus, fires on BlockFound/HealthSnapshot-with-warnings/UpdateAvailable. Per sub, spawn a task so a slow push service doesn’t block the others. On WebPushError::Gonedelete_push_subscription_by_id (browser invalidated the sub). On other errors → touch_push_subscription(error) for UI debugging. render_push_event helper 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). New webPush store in notifications.svelte.ts with 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/notifications with subscribe/unsubscribe button + iOS hint (required: add to home screen) + operator setup snippet.
  • 21.5 Tests + docs (THIS SESSION) — notifications.md new “Web Push (PWA, VAPID)” section with operator setup + user subscribe flow + events that push + iOS quirks + security note. configuration-reference.md new 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 ElectricityConfig in MiningConfig.electricity with 3-layer priority (Solar/TOU/Default). Pure-fn effective_rate(now) + slot_matches helper, 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/energy with current_power_w, current_eur_per_hour, last_{24h,7d}_kwh+eur, current_rate_source (“tou:HT”|“default”|“none”). Storage helper energy_kwh_in_last_hours() aggregates across all miners. Pure-fns sum_current_power + describe_rate_source separately testable. +8 tests.

  • 20.3 Health anomaly detector (THIS SESSION) — Pure-fn detect_anomalies(points, now, thresholds) in new warppool-storage::anomalies module. 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). Configurable AnomalyThresholds. Stale-data blocks other heuristics (no point analyzing an inactive miner). Thermal+Hashrate are deduplicated (no double-alert). API endpoint GET /api/miners/:id/alerts. +13 tests (11 detector + 2 api).

  • 20.5 Solar HA provider (THIS SESSION) — Home Assistant REST API adapter. SolarConfig extended with kind (“home_assistant”), url_env/token_env for auth, pv_entity_id (required) + optional consumption_entity_id, poll_interval_secs (default 60), surplus_buffer_w (default 200W), stale_after_secs (default 300s), excess_rate_eur_kwh (default 0.0). New SolarSnapshot {pv_w, consumption_w, at} + SolarSnapshotCache = Arc<RwLock<Option<...>>> type. effective_rate restructured: effective_rate_with_context(now, solar_snap, pool_power_w) is the new main procedure with 3-layer priority. API side: Pure-async-fn fetch_ha_entity_w parses HA’s /api/states/<id> response incl. unit conversion (W/kW/MW). fetch_ha_snapshot orchestrates PV + optional consumption. /api/energy response extended with solar: Option<SolarStatus> field (pv_w/consumption_w/excess_w/age_seconds) and new current_rate_source = "solar-excess" value. Daemon: solar_poll_loop task spawns when solar.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/alerts with severity tone), history card with range picker (1h/6h/24h/7d) + 5 TelemetrySparkline components (Hashrate/Power/Temp/Voltage/Fan). Refresh 30s. TelemetrySparkline.svelte as a simple SVG line chart, parameterizable via field/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 an on_health_alert: bool config field (default true, Critical alerts indicate hardware damage). Sink-specific rendering: ntfy with rotating_light tag + priority 5 for Critical; Slack with :rotating_light: emoji + severity label in body; Telegram uses render_text fallback. Daemon-side anomaly_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.svelte component 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 keys energy.* 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 warnings clean.
  • 32.2 🔴 CRITICAL: Merkle root wtxid→txid + byte order (THIS SESSION) — job-builder used tx.hash (segwit wtxid) instead of tx.txid AND 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 with bad-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_jobvalidator.clear_all() (was unbounded for the process lifetime); (d) Sv2 connection cap: max_connections was dead config → now a semaphore (0=unlimited); (e) Sv2 extranonce length check in precheck_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); new server.cookie_secure config flag (session cookie ; Secure when 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_prefix length validation (≤64 bytes — same silent-block-loss class as Merkle: too long → coinbase scriptSig >100 bytes), Sv2 read_buf cap at MAX_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) → daemon submitting 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 thrown bad-txnmrklroot here. 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/JobSnapshot carry extranonce_total_size (= V1 extranonce1+2); new pow::standard_extranonce fills the extranonce gap deterministically per channel (4-byte prefix + null padding); build_job_frames computes the real per-channel merkle root (prefix + standard_extranonce + suffix → txid → merkle_path); new pow_check_standard does the full PoW check + buffers a BlockSolution (same daemon submit path as Extended). merkle_root field removed from JobUpdate (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: getblocktemplate was hardcoded with ["segwit"] — Signet requires ["segwit","signet"] → RPC error -8, no template (occurs ONLY on Signet, never regtest/main/test). Fix: network-dependent rules via gbt_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, nbits 1d15102a, 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=off in the default log filter — the ERROR spam “Invalid DNS message” on malformed LAN packets, noticed during the Signet test); Sv2 ChannelRegistry counter overflow-safe (wrapping_add instead of += 1, no debug panic on u32 overflow); codec::decode_str_u8 now returns FrameError::InvalidUtf8 on UTF-8 error instead of misleading BadCompactInt; Sv2 connection-cap hot-switch coupled to the admin profile switch (semaphore pulled from the serve loop into Sv2ServerHandle + resize_connection_cap/connection_cap analogous to V1; daemon StratumCapResizer now 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; Secrets Debug derive (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 VarDiffresolved in v1.12.0: Sv2 channels now run the same VarDiff engine 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_handle was consumed in the daemon setup by sv2_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 a NewExtendedMiningJob frame and the V1 sim-miner never receives a mining.notify. Phase 7.3b notes said “job push is forwarded to the handle below in job_refresh_loop” — but that was never written. Fix: Sv2ServerHandle annotated with #[derive(Clone)] (was previously unique-owned); in the daemon setup sv2_handle.clone() into a pushable variable before serve(); job_refresh_loop signature extended with Option<Sv2ServerHandle>; after handle.push_job(...) (V1) additionally sv2.push_job(JobUpdate {...}) for V2 with mapping from V1 StratumJob (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) New pow module in crates/stratum-v2/src/pow.rs with sha256d / reconstruct_coinbase / compute_merkle_root / build_header / nbits_to_target / hash_meets_target (+ 6 unit tests, sha2 dep new); (2) MiningServerSession gets current_job: Option<JobSnapshot>, set via record_current_job from the server loop after every broadcast (server loop converts JobUpdate.prev_hash_be → internal LE per byte-reverse); (3) handle_submit_share_extended does inline PoW check (pow_check_extended): coinbase = prefix+pool_extranonce+miner_extranonce+suffix → txid → merkle_root via path → 80-byte header → sha256d → compare vs nbits_to_target (BlockFound) or channel.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 via drain_block_solutions + broadcasts via new Sv2ServerHandle::block_solution_tx. Daemon block_submit_loop extended: second select arm for Sv2 solutions, converts via sv2_solution_to_event to V1 BlockFoundEvent (Sv2 coinbase already has extranonce built in = exactly what build_full_block_hex expects as stripped coinbase). Submit logic extracted into shared submit_found_block fn (V1 + Sv2 share it). Daemon subscribes subscribe_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 warnings clean. 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 in shares_raw / pool_stats. Fix: (a) New ShareSink trait in crates/stratum-v2/src/server.rs analogous to V1, Sv2Server::with_sinks(...) constructor; (b) MiningServerSession collected accepted-share records (worker + difficulty + was_block) in an internal accepted_buffer, server loop drains after every process_frame and spawns share_sink.record() per entry (non-blocking); (c) Difficulty is computed from the channel target via top-8-byte approximation (sv2_target_to_difficulty); (d) Daemon StorageShareSink impls both V1 ShareSink and V2 warppool_stratum_v2::server::ShareSink traits — same record_share call underneath.
  • 🐛 BUG FOUND: worker counter generally broken (THIS SESSION) — While implementing the above Sv2 wiring it turned out: Storage::record_share writes only to shares_raw, not to the workers table. pool_stats.total_shares_accepted however aggregates SUM(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 checked total_blocks_found, which is updated via separate touch_worker(_, _, _, 1, _) call in the block_submit_loop. Fix: record_share now additionally calls touch_worker(worker, 1, 0, 0, None) — accepted counter += 1, rejected/blocks stay zero (blocks_delta is still set separately by block_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 used touch_worker directly, 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 in apps/dvb-warppool-setup/src/main.rs that checks: (a) bitcoind (or bitcoind.exe on Windows) in PATH via pure-Rust PATH walk, (b) execute bitcoind --version first line if binary found, (c) OS detection via std::env::consts::OS, (d) Linux distro detection via /etc/os-release (ID + ID_LIKE → debian/fedora/arch/alpine/other). Returns BitcoinInstallStatus { binary_path, version_string, os, linux_distro, suggestion }. Pure-fn install_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-status on 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 gets install-card-warn class 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.svelte upgraded 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-wizard index.html with identical values — layer 2 via .stars::after pseudo-element (setup wizard has only 1 .stars div). @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 correct Content-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-WarpPool is no longer operator-configurable in the setup wizard (UI card “Branding” replaced with a pure “Server Location” card, default value "Korschenbroich NRW, Germany"). Backend in apply() deliberately ignores incoming status_brand_name fields (let _ = form.status_brand_name;) so older wizard versions or malicious POSTs cannot overwrite the brand. BrandingConfig::default() sets status_brand_name = "dvb-WarpPool" as hardcoded brand identity. Live-verified: POST with injected status_brand_name="someone-tries-to-rebrand-this" → config.toml shows status_brand_name = "dvb-WarpPool" (ignored) and server_location = "Korschenbroich NRW, Germany" (accepted).
  • 30.5 Submit button only active when required fields filled (THIS SESSION) — Analogous to dvb-goPool: #submit-btn is initially disabled + greyscale, only becomes active when all required fields are valid. Reactive input listener 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 the disabled attribute 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_addr was 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). required attribute on <input> + JS validation checks tcp://host:port format. 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: I18N object with ~55 keys × 2 locales (DE as default, EN as fallback). All h2/labels/buttons/hints/notes have data-i18n="key", placeholders with data-i18n-placeholder, HTML-permitted with data-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) use t() directly; install status is re-rendered on locale switch via cached window._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.sh starts bitcoind-regtest with RPC :28443 + ZMQ hashblock :28332 + cookie auth. Local config in /tmp/warppool-live/{config.toml,secrets.toml} with regtest address bcrt1qtau…tklgl8mmt, profile=klein (small), min_diff=0.001, ratelimit off. Daemon --release build (10.9 MB) run against the regtest node, UI served by the daemon via --ui-dir. 101 blocks generated via generatetoaddress to 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 as accepted: true in /api/blocks (see next point for the bug fix that enabled this).
  • 🐛 BUG FOUND: submit_block null response (THIS SESSION) — Bitcoin Core’s submitblock RPC returns "result": null on successful submit. The bitcoin-rpc client in call_once (crates/bitcoin-rpc/src/lib.rs:224) called envelope.result.ok_or(MissingField("result"))?, which on Option<Value> deserialize maps both missing-field and value-null to None → false MissingField error. Result in the first live run: pool reported “rpc-error” + accepted=false for all found blocks, even though Bitcoin Core actually accepted them (chain height grew nonetheless). Fix: unwrap_or(serde_json::Value::Null) instead of ok_or(MissingField)submit_block’s match arm already correctly implemented the Value::Null path. 2 new tests: submit_block_null_response_classified_as_accepted and submit_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_log had evict_audit_older_than in storage but was never called in the daemon → real gap since Phase 3. shares_raw/shares_agg_5min/miner_telemetry_raw/miner_telemetry_agg5 had evict calls in aggregate_loop but with literal hardcoded 3600/7*24*3600 values. New RetentionConfig in crates/config/src/lib.rs as [retention] block with 5 TTL fields (defaults match the old hardcoded values; audit_log_secs default 90 days). aggregate_loop signature extended with retention, all evict calls now use config values. audit_log eviction 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 via semaphore.add_permits(delta) (immediately visible); decrease via spawned acquire_many_owned(delta).forget() task that waits until enough permits free up — natural drain without kicking existing connections. Current cap is held in the new cap_tracker: Arc<AtomicUsize> in the handle. New ConnectionCapResizer trait in the warppool-api crate (crates/api/src/lib.rs); AppState.connection_cap_resizer as optional Arc<dyn ...> (None in tests). post_admin_profile handler now calls resize(new_profile.connection_cap) and reflects this in the response (new connection_cap field). Daemon-side StratumV1CapResizer adapter with OnceLock<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: on onerror the socket is explicitly closed and a custom reconnect via exponential backoff (BACKOFF_MS = [1s, 2s, 4s, 8s, 16s, 30s]) is scheduled. Counter state.reconnectAttempts (UI-readable for “reconnecting…” banner display), reset to 0 after successful onopen. Pure helper backoffMsForAttempt(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 with UpnpConfig { enabled, renew_interval_secs, forwards: Vec<UpnpForwardSpec> }; each UpnpForwardSpec has port, protocol (tcp/udp), lease_seconds, description + a validate() fn that checks port>0, protocol∈{tcp,udp} (case-insensitive), lease∈60..=86400. Daemon-side (apps/dvb-warppool-daemon/src/main.rs): upnp_renew_loop as tokio task; per tick spawn_blocking(run_upnp_renew_once) which calls igd::search_gateway once + then add_port per spec (idempotent on the router side). Pure-data UpnpRenewReport { ok, failed, last_error } so the loop and the blocking helper are separately testable. clamp_upnp_interval clamps 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_submit in 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) but share.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 method MiningServerSession::record_job_broadcast(job_id) called after build_job_frames (server loop in handle_connection), iterates the ChannelRegistry and sets ch.current_job_id. Previously the current_job_id field 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 a record_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 additional push_job + drain of the resulting mining.notify line before mining.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 signature HashMap<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 fn prune_pending_submits is called before every insert: first TTL pruning (single-pass O(n) retain), then — if still above cap — oldest-by-timestamp eviction. Both remove sites in SubmitSharesSuccess/SubmitSharesError handlers 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 via build_job_frames (stratum-v2/src/server.rs:323); translator uses OpenExtendedMiningChannel by 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) in ui/static/icons/: 16/32/64/180/192/256/384/512 square PNGs with preserved transparency for PWA purpose: any. Additionally icon-{192,256,384,512}-maskable.png for 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.webmanifest icons array extended with 4× any + 2× maskable PNG entries (favicon.svg remains primary for modern browsers with any-sized SVG support). app.html new: <link rel="alternate icon" href=".../favicon.ico"> + <link rel="apple-touch-icon"> + 3 apple-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 #05060B background.
  • 25.4 README header with logo (THIS SESSION) — README.md gets 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 in build/ 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:text with 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-brand CSS var removed from app.css, .title + .dot original 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.svg was an old abstract purple-cyan design with no logo relation. Newly drawn SVG (ui/static/favicon.svg) adopts the logo DNA: dark #05060B rounded-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 warnings green. Fixed: 26× field_reassign_with_default annotated over test modules with #[allow(...)] (6 test modules: config / api / notifier / stratum-v1::ratelimit + ::vardiff / job-builder); 2× manual_ignore_case_cmp in avalon-probe (.eq_ignore_ascii_case); 1× type_complexity in whatsminer-probe (5-tuple → type StatsExtract); 2× get_first in stratum-v1::messages (.first() instead of .get(0)); 2× match_like_matches_macro in the daemon (debounce checks); 1× explicit_auto_deref in notifier metrics_snapshot; 1× useless_vec in webpush test (&[0u8; 16]); 1× expect_fun_call in telemetry; 1× non_snake_case test name (cgminer_status_E_yields_error_e_); 1× doc_lazy_continuation in hwdetect; 1× too_many_arguments in job_refresh_loop via #[allow] (central wiring wrapper, 10 args are essential). Bench crates/job-builder/benches/build_job.rs rewritten directly to MiningConfig { ..Default::default() } pattern (not test code, therefore no allow).
  • 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) → NotifierTest subcommand re-implemented with real POST /api/admin/notifier/test call incl. sink=all special path and tabular ok/err output; SetProfile doc comment clarified. 2× in sim-binary stubs (Load/Failures) — replaced by Phase-3 scenario subcommands → clear exit-2 with pointer to existing command instead of println!("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-features now 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 warning th.num unused in /blocks → resolved by correctly marking the height-column header as class="num", semantically consistent with td.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_error field in /api/miners shows 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::BraiinsOS enum 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). New probes/braiins.rs with own field-schema extension: power_consumption_w (Braiins exposes this, stock doesn’t), voltage with V→mV heuristic (< 100 = V), miner_version/bos_version as firmware string. Default model “Antminer (Braiins OS)” when STATS has no Type field. Skipped temp_avg/temp_max keys so the hottest chip comes out of temp1..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_avalonq Home Assistant template + gbechtel-beck/avalon-q-controller. New Vendor::AvalonQ enum variant + parse("avalonq"|"avalon-q"|"avalon_q") + display “Avalon Q (Canaan)”. New probes/avalonq.rs with AvalonQVendor: CgminerVendor. Hashrate from THSspd (×1000 for GH/s) with fallback to SUMMARY fields. Temperature TMax preferred, fallback TAvg. Power from Cur_Load. Fan maximum from Fan1-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) -> String renders the last_telemetry_json fields from the miners table 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_loop signature extended with discovery_cache: Option<DiscoveryCache>, discovered_telemetry: Option<DiscoveredTelemetryCache>, auto_probe_discovered: bool. If env WARPPOOL_AUTO_PROBE_DISCOVERED=true|1|yes is 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; /metrics renders them with label="discovered" so PromQL can separate. 2 helper tests + 1 e2e test.

  • 22.3 Tests + docs (THIS SESSION) — docs/book/src/observability.md extended 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.md extended with WARPPOOL_AUTO_PROBE_DISCOVERED env 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 with cargo_bench_support feature (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); artifact criterion-reports-<sha> with 30d retention. docs/book/src/benchmarks.md with 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) — Notifier struct gets counters: tokio::sync::Mutex<HashMap<(sink, event_kind, result), u64>>. inc_counter called from notify() + test_sinks(). metrics_snapshot() returns Vec<NotifierMetric> for /metrics handler. Per-sink view on send successes/failures.
  • 16.2 Worker lifecycle metrics (THIS SESSION) — New crates/telemetry/src/metrics.rs with PoolMetrics struct: AtomicU64/AtomicI64 counters for workers_authorized_total, workers_disconnected_total, active_connections_{v1,v2}. NotifierConnectionSink gets metrics: 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>> + builder with_metrics(arc). BitcoinRpc::call wrapped in Instant::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). Inner call_with_retry is the old logic moved.
  • /metrics handler in warppool-api extended 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 ConnectionSink in crates/stratum-v2/src/session.rs with signature identical to v1. Sv2ServerConfig.connection_sink: Option<SharedConnectionSink>. Sv2Server::with_connection_sink(...) as new ctor. handle_connection wraps the loop in async {...}.await so on_disconnect fires on both paths (Ok-exit + Err-exit). notified_users: HashSet<String> tracks seen user_identity strings; after each session.process_frame, iterate session.channels(), every NEW user_identity fires on_authorized (spawned). On loop exit: on_disconnect for 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. Shared handle_disconnect method 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) fire NotifyEvent::RpcDown {duration_secs:0}, on (Some(false), true) fire RpcRecovered with down_since.elapsed().as_secs() as debug log. Inline 4-arm match.
  • 15.2 MinerDisconnect (V1) (THIS SESSION) — New ConnectionSink trait in warppool-stratum-v1 analogous to ShareSink: on_authorized(worker, peer) + on_disconnect(worker, peer). SessionRunArgs gets connection_sink: Option<SharedConnectionSink>, Session::run fires hooks in spawned task. Daemon adapter NotifierConnectionSink {notifier, debounce, last_notified: Mutex<HashMap<String,Instant>>} with env-configurable WARPPOOL_DISCONNECT_DEBOUNCE_SECS (default 30).
  • 15.3 Email sink (THIS SESSION) — lettre = "0.11" workspace dep with tokio1-rustls-tls + smtp-transport + builder + hostname + ring features (rustls instead of openssl-sys). EmailSink parses smtps://user@host:465 (implicit TLS) or smtp://user@host:587 (STARTTLS); auth via password_env config field. Extended with on_miner_disconnect + on_rpc_down toggles. 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>>. New Notifier::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 log notifier.test. 6 new API tests.
  • 15.6 UI admin section (THIS SESSION) — src/lib/api.ts admin.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 for eval) + scripts/regtest-down.sh (clean stop + optional --purge). crates/bitcoin-rpc/tests/regtest_e2e.rs with 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 new HealthBanner.svelte component renders them as a persistent banner below the header (visible on all pages via +layout.svelte). TypeScript types (ui/src/lib/types.ts): HealthSnapshotEvent + UpdateAvailableEvent interfaces, extended into PoolEvent union. PoolEventType derived type automatically gets both. events.svelte.ts ALL_TYPES array extended with ‘update_available’ + ‘health_snapshot’ so EventSource registers the named events. Component behavior: lastHealth + lastUpdate state, both separately dismissable per session (no localStorage — after tab reload they come back). maybeRevive helper: 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-health crate (Phase 13a) for continuous Bitcoin Core diagnostics in the running pool. env var WARPPOOL_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 new PoolEvent::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-line bitcoin_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-existing io::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). BitcoinHealthReq gets optional bitcoin_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. BitcoinHealth gets recommended_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 igd crate (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). contacted field 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.md with 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 to lto = "fat" + explicit incremental = false for deterministic LTO without thin-parallelism drift. New .github/workflows/repro.yml builds dvb-warppool-daemon in two matrix jobs (a/b) on two fresh Linux x86_64 runners with identical SOURCE_DATE_EPOCH from commit timestamp and RUSTFLAGS=--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 via slsa-github-generator has 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.pub in the repo root, SHA256SUMS.bundle). Keyless-OIDC artifacts exist only for releases ≤ v1.15.0.
  • SBOM (Syft) — shipped, sbom.spdx.json in every release via anchore/sbom-action.
  • 8c Auto-update foundation (THIS SESSION) — New crate warppool-autoupdate with 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 in dvb-warppool-cli: check-update [--repo X] (HTTP fetch + version compare against CARGO_PKG_VERSION + asset list; --json for scripting), download-update --sha256 X --to PATH [--asset Y] (auto-asset selection via host_asset_substring() for linux-x86_64/-aarch64/macos-x86_64/-aarch64/windows-x86_64, otherwise manual --asset override; 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 in warppool-api: GET /api/admin/update-check (HTTP fetch latest + compare against CARGO_PKG_VERSION; response incl. assets + newer flag; audit update.check), POST /api/admin/update {asset, sha256, target_path, backup_path?} (download_verified + atomic_swap from inside the daemon process; no restart — restart_hint field shows systemctl restart dvb-warppool; audit update.applied/update.failed with details). Both 503 when checker=None. Daemon reads WARPPOOL_AUTOUPDATE_REPO env 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 SSE update_available event).
  • 8e Periodic auto-check + SSE event (THIS SESSION) — New PoolEvent variant UpdateAvailable{at, current, latest, name, prerelease} with snake_case type tag update_available. Daemon spawns periodic background task when WARPPOOL_AUTOUPDATE_REPO AND WARPPOOL_AUTOUPDATE_INTERVAL_HOURS > 0 (default 24, 0=disabled): every N hours fetch_latest + is_newer_than(CARGO_PKG_VERSION) → on newer release bus.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.service extended with StartLimitInterval=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 — uses ls -1t instead of GNU-only find -printf): finds newest backup in $WARPPOOL_BACKUP_DIR (default /var/lib/dvb-warppool/backup), atomic install via install -m 755 + mv -f (chown implicit since running as root), moves consumed backup to .applied-<timestamp> (prevents restart loop), systemctl restart --no-block so 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 var WARPPOOL_COSIGN_BIN must be set when cosign_verify=true — otherwise 500 (NOT silent skip, so the operator doesn’t get false security). Cosign exit≠0 → 403 + audit update.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 via actions/deploy-pages@v4 to 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/book runs cleanly, all 9 includes resolve.