//! The stats API in the shape WhatToMine and MiningPoolStats read, and the pool's page. A small HTTP/1.1 server: //! GET only, JSON, `Connection: close`, CORS open. The field lists are the contract the fixture test asserts. use crate::pool::Pool; use crate::state::{ign, unix_ms}; use serde_json::{json, Value}; use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; pub const STATS_FIELDS: &[&str] = &["ok", "now", "pool", "network", "source"]; pub const POOL_FIELDS: &[&str] = &[ "name", "url", "address", "algorithm", "scheme", "fee_percent", "min_payout_ign", "pplns_window_blocks", "hashrate", "hashrate_unit", "hashrate_1h", "miners", "workers", "shares", "blocks_24h", "blocks_confirmed_24h", "blocks_orphaned_24h", "blocks_total", "blocks_confirmed_total", "blocks_orphaned_total", "last_block", "effort_current", "luck_24h", "paid_24h_ign", "paid_total_ign", "pending_balance_ign", "pool_fee_total_ign", "uptime_s", "share_check_ms", "dry_run", ]; pub const NETWORK_FIELDS: &[&str] = &["name", "chain_id", "difficulty", "hashrate", "hashrate_unit", "daa_score", "block_count", "blue_score", "block_reward_ign", "miner_reward_ign", "block_time_target_s", "synced", "node_version", "epoch_seed", "epoch_index", "updated_ms"]; pub const BLOCK_FIELDS: &[&str] = &["hash", "daa_score", "blue_score", "time", "ts_ms", "finder", "worker", "status", "reward_ign", "fee_ign", "effort", "payees", "confirmed_ms", "nonce", "shift"]; pub const MINER_FIELDS: &[&str] = &["ok", "address", "hashrate", "hashrate_1h", "hashrate_reported", "shares", "workers", "blocks", "balance_ign", "paid_ign", "payments", "last_share_ms", "first_seen_ms", "online", "proving"]; pub const PAYMENT_FIELDS: &[&str] = &["time", "ts_ms", "address", "amount_ign", "tx_hash", "status", "dry_run", "nonce"]; pub const HIVE_FIELDS: &[&str] = &["hashrate", "miners", "workers", "blocks", "lastBlock", "fee", "minPayout", "scheme", "symbol", "algo", "difficulty", "networkHashrate", "height"]; fn block_json(b: &crate::state::BlockRec) -> Value { json!({ "hash": b.hash, "daa_score": b.daa_score, "blue_score": b.blue_score, "time": iso(b.found_ms), "ts_ms": b.found_ms, "finder": b.finder, "worker": b.worker, "status": b.status, "reward_ign": ign(b.reward_wei), "fee_ign": ign(b.fee_wei), "effort": round(b.effort, 4), "payees": b.payees.iter().map(|p| json!({"address": p.address, "fraction": round(p.fraction, 6)})).collect::>(), "confirmed_ms": b.confirmed_ms, "nonce": b.nonce, "shift": b.shift, }) } fn payment_json(p: &crate::state::PaymentRec) -> Value { json!({ "time": iso(p.at_ms), "ts_ms": p.at_ms, "address": p.address, "amount_ign": ign(p.amount_wei), "tx_hash": p.tx_hash, "status": p.status, "dry_run": p.dry_run, "nonce": p.nonce }) } fn iso(ms: u64) -> String { // UTC, seconds; no chrono dependency let s = ms / 1000; let (days, rem) = (s / 86400, s % 86400); let (h, m, sec) = (rem / 3600, (rem % 3600) / 60, rem % 60); // civil from days (Howard Hinnant's algorithm) let z = days as i64 + 719468; let era = z.div_euclid(146097); let doe = z.rem_euclid(146097); let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe + era * 400; let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let mo = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if mo <= 2 { y + 1 } else { y }; format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{sec:02}Z") } fn round(v: f64, places: i32) -> f64 { let f = 10f64.powi(places); (v * f).round() / f } pub fn stats(pool: &Pool) -> Value { let net = pool.net.lock().unwrap().clone(); let (miners, workers) = pool.online(); let s = pool.state.lock().unwrap(); let (f24, c24, o24) = s.blocks_in(86400); let last = s.blocks.iter().rev().find(|b| b.status != "orphan").or(s.blocks.last()); let (n, mean, p50, p99, max) = s.check_cost(); let pending: u128 = s.balances.values().sum(); let paid_total: u128 = s.paid.values().sum(); // luck over 24 h: expected blocks from the weight of accepted shares against confirmed blocks found let shares_24h_weight: f64 = s.blocks.iter().filter(|b| b.found_ms >= unix_ms().saturating_sub(86_400_000)).map(|b| b.effort).sum::() + s.pplns.since_block; let luck = if f24 > 0 && shares_24h_weight > 0.0 { f24 as f64 / shares_24h_weight } else { 0.0 }; json!({ "ok": true, "now": iso(unix_ms()), "pool": { "name": pool.cfg.name, "url": pool.cfg.public_url, "address": pool.pool_address_hex, "algorithm": "igneum", "scheme": "PPLNS", "fee_percent": pool.cfg.fee_percent, "min_payout_ign": pool.cfg.min_payout_ign, "pplns_window_blocks": pool.cfg.pplns_window_blocks, "hashrate": round(s.hashrate(600, None, None), 0), "hashrate_unit": "H/s", "hashrate_1h": round(s.hashrate(3600, None, None), 0), "miners": miners, "workers": workers, "shares": { "accepted": s.accepted, "stale": s.stale, "rejected": s.rejected, "window_weight_blocks": round(s.pplns.total_weight, 4), "window_shares": s.pplns.shares.len() }, "blocks_24h": f24, "blocks_confirmed_24h": c24, "blocks_orphaned_24h": o24, "blocks_total": s.blocks_found, "blocks_confirmed_total": s.blocks_confirmed, "blocks_orphaned_total": s.blocks_orphaned, "last_block": last.map(block_json), "effort_current": round(s.pplns.since_block, 4), "luck_24h": round(luck, 4), "paid_24h_ign": ign(s.paid_in(86400)), "paid_total_ign": ign(paid_total), "pending_balance_ign": ign(pending), "pool_fee_total_ign": ign(s.pool_fee_wei), "uptime_s": pool.started.elapsed().as_secs(), "share_check_ms": { "count": n, "mean": round(mean, 3), "p50": round(p50, 3), "p99": round(p99, 3), "max": round(max, 3) }, "dry_run": pool.cfg.dry_run, }, "network": { "name": net.network, "chain_id": net.chain_id, "difficulty": net.difficulty, "hashrate": net.hashrate, "hashrate_unit": "H/s", "daa_score": net.daa_score, "block_count": net.block_count, "blue_score": net.blue_score, "block_reward_ign": net.block_reward_ign, "miner_reward_ign": net.miner_reward_ign, "block_time_target_s": 1, "synced": net.synced, "node_version": net.node_version, "epoch_seed": net.epoch_seed, "epoch_index": net.epoch_index, "updated_ms": net.updated_ms, }, "source": "igneum-pool v0: shares verified on the CPU warp verifier; network numbers from the pool's node (getBlockDagInfo, estimateNetworkHashesPerSecond); reward by spec 2.5 at the node's DAA score", }) } pub fn blocks(pool: &Pool, limit: usize) -> Value { let s = pool.state.lock().unwrap(); json!({ "ok": true, "blocks": s.blocks.iter().rev().take(limit).map(block_json).collect::>() }) } pub fn payments(pool: &Pool, limit: usize) -> Value { let s = pool.state.lock().unwrap(); json!({ "ok": true, "payments": s.payments.iter().rev().take(limit).map(payment_json).collect::>() }) } pub fn miner(pool: &Pool, address: &str) -> Value { let address = address.to_lowercase(); if address.len() != 42 || !address.starts_with("0x") || !address[2..].chars().all(|c| c.is_ascii_hexdigit()) { return json!({ "ok": false, "error": "address is 0x followed by 40 hex" }); } let live: Vec<(String, f64, u32, bool, u32)> = pool .members_snapshot() .iter() .filter(|m| m.address == address) .map(|m| { let g = m.inner.lock().unwrap(); (m.worker.clone(), g.reported_hashrate, g.vardiff.shift, g.proving, g.reported_workers) }) .collect(); let s = pool.state.lock().unwrap(); let rec = s.miners.get(&address).cloned().unwrap_or_default(); let workers: Vec = rec .workers .iter() .map(|(name, w)| { let l = live.iter().find(|x| &x.0 == name); json!({ "name": name, "hashrate": round(s.hashrate(600, Some(&address), Some(name)), 0), "accepted": w.accepted, "stale": w.stale, "rejected": w.rejected, "blocks": w.blocks, "last_share_ms": w.last_share_ms, "online": l.is_some(), "shift": l.map(|x| x.2), "proving": l.map(|x| x.3).unwrap_or(false), "hashrate_reported": l.map(|x| x.1), }) }) .collect(); json!({ "ok": true, "address": address, "hashrate": round(s.hashrate(600, Some(&address), None), 0), "hashrate_1h": round(s.hashrate(3600, Some(&address), None), 0), "hashrate_reported": round(live.iter().map(|x| x.1).sum::(), 0), "shares": { "accepted": rec.accepted, "stale": rec.stale, "rejected": rec.rejected }, "workers": workers, "blocks": rec.blocks, "balance_ign": ign(s.balances.get(&address).copied().unwrap_or(0)), "paid_ign": ign(s.paid.get(&address).copied().unwrap_or(0)), "payments": s.payments.iter().rev().filter(|p| p.address == address).take(50).map(payment_json).collect::>(), "last_share_ms": rec.last_share_ms, "first_seen_ms": rec.first_seen_ms, "online": !live.is_empty(), "proving": live.iter().any(|x| x.3), }) } /// Hive-style pool stats: the flat camelCase object pool dashboards poll (approximate: Hive fixes no schema for a /// custom pool, so this mirrors the common shape of MiningPoolStats pool JSON). pub fn hive(pool: &Pool) -> Value { let v = stats(pool); let p = &v["pool"]; let n = &v["network"]; json!({ "hashrate": p["hashrate"], "miners": p["miners"], "workers": p["workers"], "blocks": p["blocks_total"], "lastBlock": p["last_block"].get("ts_ms").cloned().unwrap_or(Value::Null), "fee": p["fee_percent"], "minPayout": p["min_payout_ign"], "scheme": "PPLNS", "symbol": "IGN", "algo": "igneum", "difficulty": n["difficulty"], "networkHashrate": n["hashrate"], "height": n["daa_score"], }) } fn page(pool: &Pool) -> String { include_str!("../web/index.html") .replace("__POOL_NAME__", &pool.cfg.name) .replace("__POOL_URL__", &pool.cfg.public_url) .replace("__POOL_ADDRESS__", &pool.pool_address_hex) .replace("__NETWORK__", &pool.cfg.network) .replace("__FEE__", &format!("{}", pool.cfg.fee_percent)) .replace("__MIN_PAYOUT__", &format!("{}", pool.cfg.min_payout_ign)) } fn respond(status: &str, ctype: &str, body: &[u8]) -> Vec { let mut v = format!( "HTTP/1.1 {status}\r\nContent-Type: {ctype}\r\nContent-Length: {}\r\nAccess-Control-Allow-Origin: *\r\nCache-Control: public, max-age=5\r\nX-Content-Type-Options: nosniff\r\nConnection: close\r\n\r\n", body.len() ) .into_bytes(); v.extend_from_slice(body); v } pub fn route(pool: &Pool, path: &str) -> Vec { let (path, query) = path.split_once('?').unwrap_or((path, "")); let limit = query.split('&').find_map(|kv| kv.strip_prefix("limit=")).and_then(|v| v.parse::().ok()).unwrap_or(100).min(1000); let json = |v: Value| respond("200 OK", "application/json; charset=utf-8", v.to_string().as_bytes()); match path { "/" | "/index.html" => respond("200 OK", "text/html; charset=utf-8", page(pool).as_bytes()), "/api/stats" => json(stats(pool)), "/api/blocks" => json(blocks(pool, limit)), "/api/payments" => json(payments(pool, limit)), "/api/pool-stats" | "/api/hive" => json(hive(pool)), "/health" => json(json!({"ok": true})), p if p.starts_with("/api/miners/") => json(miner(pool, &p["/api/miners/".len()..])), _ => respond("404 Not Found", "application/json; charset=utf-8", br#"{"ok":false,"error":"not found"}"#), } } pub async fn serve(pool: Arc) { let listener = tokio::net::TcpListener::bind(&pool.cfg.http).await.unwrap_or_else(|e| { eprintln!("cannot listen on {}: {e}", pool.cfg.http); std::process::exit(1) }); println!("{} pool: stats API and page on http://{}/", crate::state::unix_ms(), pool.cfg.http); loop { let Ok((sock, _)) = listener.accept().await else { continue }; let pool = pool.clone(); tokio::spawn(async move { let (rd, mut wr) = sock.into_split(); let mut lines = BufReader::new(rd).lines(); let first = match tokio::time::timeout(Duration::from_secs(10), lines.next_line()).await { Ok(Ok(Some(l))) => l, _ => return, }; // drain the headers while let Ok(Ok(Some(l))) = tokio::time::timeout(Duration::from_secs(2), lines.next_line()).await { if l.is_empty() { break; } } let mut parts = first.split_whitespace(); let method = parts.next().unwrap_or(""); let path = parts.next().unwrap_or("/"); let out = if method != "GET" && method != "HEAD" { respond("405 Method Not Allowed", "application/json; charset=utf-8", br#"{"ok":false,"error":"GET only"}"#) } else { route(&pool, path) }; let _ = wr.write_all(&out).await; let _ = wr.shutdown().await; }); } } #[cfg(test)] mod tests { use super::*; fn keys(v: &Value) -> Vec { let mut k: Vec = v.as_object().expect("object").keys().cloned().collect(); k.sort(); k } fn sorted(f: &[&str]) -> Vec { let mut k: Vec = f.iter().map(|s| s.to_string()).collect(); k.sort(); k } /// The fixture (`tests/fixtures/stats.json`, a response captured from the private network run) has exactly the /// fields the contract names, and the contract matches what the handler builds. #[test] fn fixture_matches_the_field_contract() { let f: Value = serde_json::from_str(include_str!("../tests/fixtures/stats.json")).unwrap(); assert_eq!(keys(&f), sorted(STATS_FIELDS)); assert_eq!(keys(&f["pool"]), sorted(POOL_FIELDS)); assert_eq!(keys(&f["network"]), sorted(NETWORK_FIELDS)); assert_eq!(keys(&f["pool"]["last_block"]), sorted(BLOCK_FIELDS)); let m: Value = serde_json::from_str(include_str!("../tests/fixtures/miner.json")).unwrap(); assert_eq!(keys(&m), sorted(MINER_FIELDS)); let p: Value = serde_json::from_str(include_str!("../tests/fixtures/payments.json")).unwrap(); assert_eq!(keys(&p["payments"][0]), sorted(PAYMENT_FIELDS)); let h: Value = serde_json::from_str(include_str!("../tests/fixtures/pool-stats.json")).unwrap(); assert_eq!(keys(&h), sorted(HIVE_FIELDS)); // the numbers WhatToMine's form asks for are present and typed assert!(f["pool"]["hashrate"].is_number() && f["pool"]["fee_percent"].is_number() && f["pool"]["min_payout_ign"].is_number()); assert!(f["pool"]["miners"].is_number() && f["pool"]["workers"].is_number() && f["pool"]["blocks_24h"].is_number()); assert!(f["network"]["difficulty"].is_number() && f["network"]["block_reward_ign"].is_number()); } #[test] fn iso_dates_are_utc() { assert_eq!(iso(0), "1970-01-01T00:00:00Z"); assert_eq!(iso(1_790_985_600_000), "2026-10-03T00:00:00Z"); } }