Roadmap

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/sselib/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 push (web-push crate) deliberately deferred due to native-OpenSSL dependency — will come with a pure-Rust web-push backend
  • 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 (universal binary, notarized)
  • Windows .msi (signed)
  • 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 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 Wiringdeferred (2026-05-27 ecosystem re-eval): 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.

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 VarDiff (channel target fixed at diff-1; for solo only share accounting is affected, not BlockFound — separate feature, no polish).

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, 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, github.com/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 (partly in Phase 6 — cosign sign-blob keyless OIDC for SHA256SUMS already set up)
  • SBOM (Syft) (partly in Phase 6 — anchore/sbom-action already in the release)
  • 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.