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