From 959c4a5fa9affefd9286f9a3161ed1b0e60524b1 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:05:51 +0000 Subject: [PATCH 1/5] miner-ui-5 (0.3.19): the ladder's engine half: ladder.json, api/ladder chain facts, the block card's PNG src/ladder.rs: this machine's record (first block by card, hash joined to the miner's ACCEPTED line by nonce from the node's PoW accepted line, blocks per UTC day, VOTE and LOCK lines as the signing record and streak, paid shards, the milestone at 1, 100, 1,000, every 10,000th and the first block of a new card, the first-hour marks); persisted, in api/state.ladder. src/chainfacts.rs: GET /api/ladder, getFinalityWeights through the node's EVM port when it answers igneum_getFinalityWeights (owed), else the observer's relay plus /api/stats; this machine's keys ranked; the source named. src/card.rs and POST /api/card: the page's 1200x630 PNG written under /cards/ and revealed. settings.profile_public behind api/settings. Hooks in engine.rs (block, node line, vote, lock, synced, mining) and prover.rs (paid shard). Box: 190 + 27 + 8 tests. Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/card.rs | 80 +++++ app/igneum-app/src/chainfacts.rs | 263 ++++++++++++++++ app/igneum-app/src/config.rs | 6 +- app/igneum-app/src/engine.rs | 56 +++- app/igneum-app/src/ladder.rs | 514 +++++++++++++++++++++++++++++++ app/igneum-app/src/main.rs | 3 + app/igneum-app/src/platform.rs | 11 + app/igneum-app/src/prover.rs | 1 + app/igneum-app/src/server.rs | 25 +- app/igneum-app/src/state.rs | 4 + 10 files changed, 959 insertions(+), 4 deletions(-) create mode 100644 app/igneum-app/src/card.rs create mode 100644 app/igneum-app/src/chainfacts.rs create mode 100644 app/igneum-app/src/ladder.rs diff --git a/app/igneum-app/src/card.rs b/app/igneum-app/src/card.rs new file mode 100644 index 000000000..d1447675c --- /dev/null +++ b/app/igneum-app/src/card.rs @@ -0,0 +1,80 @@ +//! The saved block card (miner-ui-5): the page draws the 1200x630 PNG on a canvas from the same words the card +//! shows (no network call), posts it as a data URL to POST /api/card, and the engine writes it under +//! `/cards/`. This module is the pure part: the base64 decode (no crate for it) and the file name. +use std::path::{Path, PathBuf}; + +/// `data:image/png;base64,....` -> the PNG bytes; None for anything that is not a base64 PNG data URL. +pub fn decode_data_url(s: &str) -> Option> { + let rest = s.strip_prefix("data:image/png;base64,")?; + let bytes = decode_base64(rest)?; + if bytes.len() < 8 || bytes[..8] != [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] { + return None; + } + Some(bytes) +} + +/// Standard base64 (RFC 4648, with or without `=` padding, whitespace ignored). None on a bad character. +pub fn decode_base64(s: &str) -> Option> { + let mut out = Vec::with_capacity(s.len() * 3 / 4); + let mut acc: u32 = 0; + let mut bits = 0u32; + for c in s.bytes() { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + b'=' | b'\n' | b'\r' | b' ' | b'\t' => continue, + _ => return None, + } as u32; + acc = (acc << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push(((acc >> bits) & 0xFF) as u8); + } + } + Some(out) +} + +/// `/cards/igneum-block--.png`, the name reduced to [a-z0-9-] and at most 40 characters. +pub fn card_path(root: &Path, name: &str, unix: u64) -> PathBuf { + let mut clean = String::new(); + for c in name.to_ascii_lowercase().chars() { + if c.is_ascii_alphanumeric() { clean.push(c); } else if !clean.ends_with('-') { clean.push('-'); } + } + let clean: String = clean.trim_matches('-').chars().take(40).collect::().trim_end_matches('-').to_string(); + let clean = if clean.is_empty() { "block".to_string() } else { clean }; + root.join("cards").join(format!("igneum-block-{clean}-{unix}.png")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_decodes_with_and_without_padding() { + assert_eq!(decode_base64("aGVsbG8=").unwrap(), b"hello"); + assert_eq!(decode_base64("aGVsbG8").unwrap(), b"hello"); + assert_eq!(decode_base64("aGVs\nbG8gd29ybGQ=").unwrap(), b"hello world"); + assert!(decode_base64("aGV$sbG8=").is_none()); + } + + #[test] + fn a_png_data_url_decodes_and_anything_else_is_refused() { + // the 8-byte PNG signature, base64 + let sig = "iVBORw0KGgo="; + let png = decode_data_url(&format!("data:image/png;base64,{sig}")).unwrap(); + assert_eq!(&png[..4], b"\x89PNG"); + assert!(decode_data_url("data:image/jpeg;base64,/9j/").is_none()); + assert!(decode_data_url("data:image/png;base64,aGVsbG8=").is_none(), "not a PNG"); + } + + #[test] + fn the_file_name_is_safe_and_dated() { + let p = card_path(Path::new("/data"), "Block 1,284,117 / RTX 4070", 1_791_362_116); + assert_eq!(p, PathBuf::from("/data/cards/igneum-block-block-1-284-117-rtx-4070-1791362116.png")); + assert_eq!(card_path(Path::new("/d"), "///", 1).file_name().unwrap(), "igneum-block-block-1.png"); + } +} diff --git a/app/igneum-app/src/chainfacts.rs b/app/igneum-app/src/chainfacts.rs new file mode 100644 index 000000000..3cbc0e1d8 --- /dev/null +++ b/app/igneum-app/src/chainfacts.rs @@ -0,0 +1,263 @@ +//! GET /api/ladder: the chain facts behind the miner's ladder (miner-ui-5, 7 October 2026). Every rung the dashboard +//! shows is a number from the node's finality RPC, never a count this app keeps: `getFinalityWeights` (the per-key +//! table: `keys[].blocks` in the weight window, `keys[].voter` against `params.dust`, `keys[].participation` over +//! `params.presenceWindow`, `totalWeight`, `activeWeight`, `voters`, `checkpointIndex`) and `getFinalityCheckpoints` +//! (`latestLockedIndex`, `nextIndex`, `finalityActive`). The network rate and the reward come from the public +//! `/api/stats` (`hashrate`, `block_reward.miner_ign`, `block_reward.ramp_factor`), the observer's reading of the same +//! node RPCs. +//! +//! Sources, in order: the local node's EVM port answering `igneum_getFinalityWeights` (OWED on the node: the finality +//! RPCs are wRPC only today and the engine carries no websocket client), else the observer's `/api/live`, whose +//! `finality.weights` is the node's `getFinalityWeights` relayed (field names in snake case, `keys[]` cut to 64, key ids +//! as the first 8 hex of the key hash). The reply names its source so the dashboard can say it on hover. Cached 10 s. +use serde_json::{json, Value}; +use std::process::Command; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +static CACHE: Mutex> = Mutex::new(None); +const TTL: Duration = Duration::from_secs(10); +/// the observer relays at most this many keys (tools/observer/observer.mjs) +pub const OBSERVER_KEYS_CAP: usize = 64; + +fn num(v: &Value) -> f64 { + match v { + Value::Number(n) => n.as_f64().unwrap_or(0.0), + Value::String(s) => s.parse().unwrap_or(0.0), + Value::Bool(b) => if *b { 1.0 } else { 0.0 }, + _ => 0.0, + } +} +fn u(v: &Value) -> u64 { num(v).max(0.0) as u64 } +fn get<'a>(v: &'a Value, keys: &[&str]) -> &'a Value { + for k in keys { + if let Some(x) = v.get(k) { return x; } + } + &Value::Null +} + +/// One key's row in either spelling (the node's camelCase or the observer's snake case). +fn key_row(k: &Value) -> Value { + let id_full = get(k, &["keyHash", "key_hash", "id"]).as_str().unwrap_or("").trim_start_matches("0x").to_ascii_lowercase(); + let id: String = id_full.chars().take(8).collect(); + let revealed = match k.get("revealed") { Some(r) => r.as_bool().unwrap_or(false), None => k.get("pubkey").and_then(|p| p.as_str()).map(|p| !p.is_empty()).unwrap_or(false) }; + json!({ + "id": id, + "blocks": u(get(k, &["blocks"])), + "voter": get(k, &["voter"]).as_bool().unwrap_or(false), + "participation": num(get(k, &["participation"])), + "stripped_until_daa": u(get(k, &["strippedUntilDaa", "stripped_until_daa"])), + "revealed": revealed, + }) +} + +/// Is `id` one of this machine's key ids? A prefix of the other of at least 6 hex counts (the engine keeps 8, a +/// source may keep more or fewer). +pub fn is_mine(id: &str, ids: &[String]) -> bool { + let id = id.trim_start_matches("0x").to_ascii_lowercase(); + ids.iter().any(|m| { + let m = m.trim_start_matches("0x").to_ascii_lowercase(); + let n = id.len().min(m.len()); + n >= 6 && id[..n] == m[..n] + }) +} + +/// The reply from a weights table (`w`: the node's `getFinalityWeights` reply, or the observer's +/// `finality.weights` with `finality` beside it), the observer's `finality` block (params and the locked index), the +/// public stats, and this machine's key ids. Pure. +pub fn shape(w: &Value, finality: &Value, stats: &Value, ids: &[String], source: &str, read_at: f64) -> Value { + let params = if w.get("params").is_some() { get(w, &["params"]) } else { get(finality, &["params"]) }; + let mut keys: Vec = get(w, &["keys"]).as_array().map(|a| a.iter().map(key_row).collect()).unwrap_or_default(); + keys.sort_by(|a, b| u(&b["blocks"]).cmp(&u(&a["blocks"])).then_with(|| a["id"].as_str().cmp(&b["id"].as_str()))); + let mut mine: Vec = Vec::new(); + for (i, k) in keys.iter().enumerate() { + if is_mine(k["id"].as_str().unwrap_or(""), ids) { + let mut m = k.clone(); + m["rank"] = json!(i + 1); + mine.push(m); + } + } + let best = mine.iter().min_by_key(|m| u(&m["rank"])).cloned(); + let reward = get(stats, &["block_reward"]); + let latest_locked = if finality.get("latest_locked_index").is_some() { u(get(finality, &["latest_locked_index"])) } else { u(get(get(stats, &["finality"]), &["latest_locked_index"])) }; + let active = match finality.get("active").or_else(|| finality.get("finality_active")) { Some(a) => a.as_bool().unwrap_or(false), None => get(get(stats, &["finality"]), &["active"]).as_bool().unwrap_or(false) }; + json!({ + "ok": true, + "source": source, + "read_at": read_at, + "rpc": { + "weights": "getFinalityWeights", + "checkpoints": "getFinalityCheckpoints", + "stats": "/api/stats", + "relay": if source == "node" { "" } else { "igneum.network/api/live (the observer's copy of the node's reply)" } + }, + "params": { + "dust": u(get(params, &["dust"])), + "weight_window": u(get(params, &["weightWindow", "weight_window"])), + "presence_window": u(get(params, &["presenceWindow", "presence_window"])), + "checkpoint_interval": u(get(params, &["checkpointInterval", "checkpoint_interval"])), + "min_daa": u(get(params, &["minDaa", "min_daa"])), + }, + "checkpoint_index": u(get(w, &["checkpointIndex", "checkpoint_index"])), + "daa_score": u(get(w, &["daaScore", "daa_score"])), + "latest_locked_index": latest_locked, + "next_index": u(get(finality, &["nextIndex", "next_index"])), + "finality_active": active, + "total_weight": u(get(w, &["totalWeight", "total_weight"])), + "active_weight": num(get(w, &["activeWeight", "active_weight"])), + "voters": u(get(w, &["voters"])), + "keys_listed": keys.len(), + "keys_cap": if source == "node" { 0 } else { OBSERVER_KEYS_CAP }, + "network": { + "hashrate_hps": num(get(stats, &["hashrate"])), + "miner_ign_per_block": num(get(reward, &["miner_ign"])), + "ign_per_block": num(get(reward, &["ign"])), + "ramp_factor": num(get(reward, &["ramp_factor"])), + "daa": u(get(stats, &["daa"])), + "blocks_per_day_measured": u(get(stats, &["blocks_per_day_measured"])), + "bps": 1, + "stale": get(stats, &["stale"]).as_bool().unwrap_or(stats.is_null()), + }, + "mine": mine, + "best": best, + }) +} + +fn curl_json(url: &str) -> Option { + let out = crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "6", url]), None, Duration::from_secs(8))?; + serde_json::from_str(out.trim()).ok() +} + +/// `https://igneum.network/api/live` -> `https://igneum.network/api/stats` +pub fn stats_api_from(live_api: &str) -> String { + match live_api.strip_suffix("/api/live") { + Some(base) => format!("{base}/api/stats"), + None => String::new(), + } +} + +/// The reply for the dashboard, cached 10 s: the node first, the observer second, `{ok:false}` with the reason when +/// neither answers. +pub fn fetch(evm_port: u16, live_api: &str, ids: &[String]) -> Value { + if let Some((at, v)) = CACHE.lock().unwrap().as_ref() { + if at.elapsed() < TTL { + let mut c = v.clone(); + c["cached_age_s"] = json!(at.elapsed().as_secs_f64().round()); + return c; + } + } + let now = crate::platform::unix_now_f(); + let stats = if live_api.is_empty() { None } else { curl_json(&stats_api_from(live_api)) }; + let stats = stats.unwrap_or(Value::Null); + let reply = match crate::extnode::rpc(evm_port, "igneum_getFinalityWeights", json!([]), Duration::from_secs(4)) { + Some(w) if w.get("keys").is_some() => shape(&w, &Value::Null, &stats, ids, "node", now), + _ => match if live_api.is_empty() { None } else { curl_json(&crate::live::url_for(live_api, 60)) } { + Some(live) => { + let fin = get(&live, &["finality"]); + let w = get(fin, &["weights"]); + if w.get("keys").is_some() { shape(w, fin, &stats, ids, "observer", now) } else { json!({ "ok": false, "error": "the observer carries no finality weights yet", "source": "observer" }) } + } + None => json!({ "ok": false, "error": if live_api.is_empty() { "no observer address in this build and the node has no igneum_getFinalityWeights" } else { "neither the node nor the observer answered" }, "source": "" }), + }, + }; + *CACHE.lock().unwrap() = Some((Instant::now(), reply.clone())); + reply +} + +#[cfg(test)] +mod tests { + use super::*; + + fn observer_live() -> Value { + json!({ + "ok": true, + "state": { "hashes_per_second_estimate": 764944722 }, + "finality": { + "supported": true, "active": true, "next_index": 8496, "latest_locked_index": 8495, + "params": { "dust": 5, "minDaa": 7200, "aggregators": 8, "weightWindow": 7200, "presenceWindow": 20, "checkpointDepth": 20, "equivocationBan": 7200, "checkpointInterval": 30 }, + "weights": { + "checkpoint_index": 8495, "total_weight": 6000, "active_weight": 5900.5, "voters": 14, + "keys": [ + { "id": "6e80f3ef", "voter": true, "blocks": 680, "revealed": true, "participation": 1, "stripped_until_daa": 0 }, + { "id": "9e4ba6b0", "voter": true, "blocks": 559, "revealed": true, "participation": 1, "stripped_until_daa": 0 }, + { "id": "8fafda27", "voter": true, "blocks": 61, "revealed": true, "participation": 0.95, "stripped_until_daa": 0 }, + { "id": "00000001", "voter": false, "blocks": 3, "revealed": false, "participation": 0, "stripped_until_daa": 0 } + ] + } + } + }) + } + fn stats() -> Value { + json!({ "ok": true, "stale": false, "daa": 266454, "hashrate": 764944722, "blocks_per_day_measured": 92280, "block_reward": { "miner_ign": "4.88044084", "ign": "6.10055105", "ramp_factor": 0.192519 }, "finality": { "active": true, "latest_locked_index": 8495 } }) + } + + #[test] + fn the_observer_relay_shapes_into_the_node_fields_with_our_rank() { + let live = observer_live(); + let fin = &live["finality"]; + let r = shape(&fin["weights"], fin, &stats(), &["8fafda27".into()], "observer", 1.0); + assert_eq!(r["ok"], true); + assert_eq!(r["source"], "observer"); + assert_eq!(r["params"]["dust"], 5); + assert_eq!(r["params"]["weight_window"], 7200); + assert_eq!(r["params"]["presence_window"], 20); + assert_eq!(r["latest_locked_index"], 8495); + assert_eq!(r["finality_active"], true); + assert_eq!(r["voters"], 14); + assert_eq!(r["keys_listed"], 4); + assert_eq!(r["keys_cap"], 64); + assert_eq!(r["network"]["hashrate_hps"], 764944722.0); + assert_eq!(r["network"]["miner_ign_per_block"], 4.88044084); + assert_eq!(r["mine"].as_array().unwrap().len(), 1); + assert_eq!(r["mine"][0]["rank"], 3); + assert_eq!(r["mine"][0]["blocks"], 61); + assert_eq!(r["mine"][0]["voter"], true); + assert_eq!(r["best"]["id"], "8fafda27"); + assert!(r["rpc"]["relay"].as_str().unwrap().contains("observer")); + } + + #[test] + fn the_node_reply_in_camel_case_shapes_the_same_and_names_no_relay() { + let w = json!({ + "params": { "checkpointInterval": 30, "checkpointDepth": 20, "weightWindow": 2592000, "dust": 100, "presenceWindow": 240, "aggregators": 8, "equivocationBan": 2592000, "minDaa": 7200 }, + "checkpointIndex": 184220, "checkpointHash": "ab", "daaScore": 1284117, "totalWeight": 2000000, "activeWeight": 1900000.0, "voters": 1204, + "keys": [ + { "keyHash": "8fafda27aa11bb22cc33dd44", "pubkey": "a1", "blocks": 2592, "voter": true, "participation": 1.0, "strippedUntilDaa": 0 }, + { "keyHash": "0000000100000000", "pubkey": "", "blocks": 44, "voter": false, "participation": 0.0, "strippedUntilDaa": 0 } + ] + }); + let r = shape(&w, &Value::Null, &stats(), &["8fafda27".into()], "node", 2.0); + assert_eq!(r["source"], "node"); + assert_eq!(r["keys_cap"], 0); + assert_eq!(r["params"]["dust"], 100); + assert_eq!(r["params"]["weight_window"], 2592000); + assert_eq!(r["checkpoint_index"], 184220); + assert_eq!(r["mine"][0]["id"], "8fafda27"); + assert_eq!(r["mine"][0]["rank"], 1); + assert_eq!(r["mine"][0]["revealed"], true); + assert_eq!(r["rpc"]["relay"], ""); + // the locked index falls back to the public stats when the node reply carries no checkpoints block + assert_eq!(r["latest_locked_index"], 8495); + } + + #[test] + fn a_machine_with_no_key_in_the_table_has_no_rank() { + let live = observer_live(); + let fin = &live["finality"]; + let r = shape(&fin["weights"], fin, &Value::Null, &["deadbeef".into()], "observer", 1.0); + assert_eq!(r["mine"].as_array().unwrap().len(), 0); + assert!(r["best"].is_null()); + assert_eq!(r["network"]["stale"], true); + assert_eq!(r["network"]["hashrate_hps"], 0.0); + } + + #[test] + fn id_matching_takes_a_prefix_of_six_or_more() { + assert!(is_mine("8fafda27", &["8fafda27aa11".into()])); + assert!(is_mine("0x8fafda27aa", &["8fafda27".into()])); + assert!(!is_mine("8fafd", &["8fafda27".into()])); + assert!(!is_mine("8fafda28", &["8fafda27".into()])); + assert_eq!(stats_api_from("https://igneum.network/api/live"), "https://igneum.network/api/stats"); + assert_eq!(stats_api_from(""), ""); + } +} diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index 51beeaf75..770a3c7a5 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -129,6 +129,10 @@ pub struct Settings { /// on when the machine can prove, never switching an explicit on back off). Older installs apply it at their next start. #[serde(default)] pub prove_default_applied: bool, + /// miner-ui-5 (7 October 2026): the public address profile on the site is behind this opt-in; off by default, and + /// the switch's words say what becomes public (the key ids, the blocks, the weight rank, the card model). + #[serde(default)] + pub profile_public: bool, } fn one() -> u32 { @@ -143,7 +147,7 @@ fn yes() -> bool { impl Default for Settings { fn default() -> Settings { - Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false } + Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false } } } diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 202c62b63..bdbcd7b25 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -16,6 +16,9 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +/// miner-ui-5: a signing streak breaks when no VOTE line comes for this long (the devnet's presence window, 20 +/// checkpoints of 30 s; the chain-side fact is `getFinalityWeights.keys[].participation`, this is the local reading) +pub const LADDER_GAP_S: f64 = 600.0; const POW_EPOCH_BLOCKS: u64 = 3_600; #[derive(Clone, Debug, PartialEq)] @@ -110,12 +113,17 @@ pub struct Shared { port: std::sync::atomic::AtomicU16, /// set once `state_json` has logged a serialisation error (one line, not one per poll) state_error_logged: std::sync::atomic::AtomicBool, + /// miner-ui-5: the machine's own ladder record (src/ladder.rs), `/ladder.json` + pub ladder: Mutex, + pub ladder_path: PathBuf, } impl Shared { pub fn new(token: String, runtime: Runtime, packaged: Packaged, settings: Settings, cmd_tx: Sender, engine_log: Option, log_path: PathBuf) -> Shared { let settings_path = runtime.app_dir.join("settings.json"); let wallet_path = runtime.app_dir.join("wallet.json"); + let ladder_path = runtime.app_dir.join("ladder.json"); + let ladder = crate::ladder::Ladder::load(&ladder_path); let mut st = State { version: VERSION.into(), ..Default::default() }; st.network = runtime.network.clone(); st.chain = if runtime.network == "devnet" { "devnet v4".into() } else { format!("{} (private)", runtime.network) }; @@ -133,7 +141,7 @@ impl Shared { st.mining.accepted_total = settings.accepted_total; st.mining.fee_total = settings.fee_total; st.address = address_state(&settings, &wallet_path); - st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust }; + st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust, profile_public: settings.profile_public }; st.dev_fee = crate::state::DevFeeState { on: settings.dev_fee, percent: if settings.dev_fee { 1 } else { 0 }, address: String::new(), line: String::new() }; st.live_page = packaged.live_page.clone(); st.finality.message = "waiting for the miner".into(); @@ -155,9 +163,21 @@ impl Shared { log_path, port: std::sync::atomic::AtomicU16::new(0), state_error_logged: std::sync::atomic::AtomicBool::new(false), + ladder: Mutex::new(ladder), + ladder_path, } } + /// miner-ui-5: the ladder record to disk (a few KB; after a block, a lock or a paid shard) + pub fn save_ladder(&self) { + self.ladder.lock().unwrap().save(&self.ladder_path); + } + /// miner-ui-5: a paid shard record of ours (src/prover.rs) joins the ladder's shard list + pub fn ladder_shard_paid(&self, block: u64, shard: u64, wei: u128) { + self.ladder.lock().unwrap().on_shard_paid(crate::platform::unix_now_f(), block, shard, wei); + self.save_ladder(); + } + /// The first line of every upload, so the console's Machines tab parses it without guessing: /// IGNEUM-APP version= machine= platform= node=. Logged at every engine start too, /// so the restart after an OTA apply carries it again. @@ -197,6 +217,7 @@ impl Shared { st.now = crate::platform::unix_now_f(); st.uptime_s = self.started.elapsed().as_secs(); st.events = self.rings.lock().unwrap().events.iter().cloned().collect(); + st.ladder = self.ladder.lock().unwrap().state(st.now, LADDER_GAP_S); match serde_json::to_value(st) { Ok(v) => v, Err(e) => { @@ -333,7 +354,7 @@ impl Shared { Ok(json!({ "ok": true })) } - pub fn apply_settings(&self, identities: Option, vote: Option, login: Option, address: Option<&str>, display_name: Option<&str>, dev_fee: Option, proof_verify_trust: Option) -> Result { + pub fn apply_settings(&self, identities: Option, vote: Option, login: Option, address: Option<&str>, display_name: Option<&str>, dev_fee: Option, proof_verify_trust: Option, profile_public: Option) -> Result { let mut restart = Vec::new(); let mut restart_node: Option = None; { @@ -367,6 +388,12 @@ impl Shared { restart_node = Some(if v { "proof trust mode on (devnet only)".into() } else { "proof trust mode off".into() }); } } + if let Some(v) = profile_public { + if v != s.profile_public { + s.profile_public = v; + self.event("info", if v { "public profile on: your key ids, blocks, weight rank and card model may be shown on igneum.network" } else { "public profile off: the address page stays unlisted" }); + } + } if let Some(a) = address { let a = a.trim().to_ascii_lowercase(); if !a.is_empty() && a != s.address { @@ -386,6 +413,7 @@ impl Shared { st.settings.vote = s.vote; st.settings.dev_fee = s.dev_fee; st.settings.proof_verify_trust = s.proof_verify_trust; + st.settings.profile_public = s.profile_public; st.dev_fee.on = s.dev_fee; st.dev_fee.percent = if s.dev_fee { 1 } else { 0 }; st.address = address_state(&s, &self.wallet_path); @@ -3813,6 +3841,11 @@ impl Engine { /// igneumd's own lines. One matters for the user: relayed blocks refused as "too far into the future", which is /// this machine's clock running behind the network (PC 2, 4 October 2026: 60 s slow after a power cut, 0 blocks). fn node_line(&mut self, text: &str) { + // miner-ui-5: every block the node validates, ours and relayed, by nonce; the miner's ACCEPTED line claims ours + if let Some((hash, daa, nonce)) = crate::ladder::parse_node_accepted(text) { + self.shared.ladder.lock().unwrap().on_node_accepted(&hash, daa, &nonce); + return; + } if let Some(d) = digest_from_line(text) { let mut st = self.st(); if st.node.consensus_digest != d { @@ -4002,6 +4035,8 @@ impl Engine { } if synced && !was_synced { self.shared.event("ok", &format!("node synced: {blocks} blocks, {peers} peer(s)")); + // miner-ui-5: the first-hour timeline's "node synced" mark, once per install + if self.shared.ladder.lock().unwrap().mark("synced", crate::platform::unix_now_f()) { self.shared.save_ladder(); } let t = self.secs(now); let names: Vec = self.st().mining.cards.iter().map(|c| c.name.clone()).collect(); for m in self.miners.iter_mut() { @@ -4127,8 +4162,16 @@ impl Engine { if let Some(c) = st.mining.cards.get_mut(card) { c.accepted += 1; } + let card_key = st.mining.cards.get(card).map(|c| c.key.clone()).unwrap_or_default(); drop(st); self.shared.event("block", &format!("block accepted by the node ({card_name})")); + // miner-ui-5: the machine's own ladder record (the block card, the days ring); the hash joins by nonce + let nonce = crate::ladder::parse_miner_accepted(text).unwrap_or_default(); + let milestone = self.shared.ladder.lock().unwrap().on_block(unix, &card_key, &card_name, &nonce, total); + self.shared.save_ladder(); + if let Some(m) = milestone { + self.shared.event("block", &crate::ladder::milestone_event(&m)); + } } else if let Some(s) = crate::watchdog::parse_status(text) { let now = Instant::now(); self.miners[i].last_status = Some(now); @@ -4167,6 +4210,8 @@ impl Engine { c.message = if s.mismatched > 0 { format!("{} share(s) failed the CPU re-check this run", s.mismatched) } else { String::new() }; } } + // miner-ui-5: the first-hour timeline's "card mining" mark, once per install, on the first status with a rate + if s.hash_now > 0.0 && self.shared.ladder.lock().unwrap().mark("mining", crate::platform::unix_now_f()) { self.shared.save_ladder(); } } else if text.contains(" worker: race ") { self.race_line(card, &card_name, text); } else if text.contains(" worker: ready ") { @@ -4228,9 +4273,16 @@ impl Engine { st.finality.last_lock_at = crate::platform::unix_now_f(); st.finality.age_s = 0.0; } + drop(st); + // miner-ui-5: every checkpoint this machine signed at or under the lock is in a certificate now + self.shared.ladder.lock().unwrap().on_lock(n); + self.shared.save_ladder(); } } else if text.contains(" VOTE index=") { self.st().finality.votes += 1; + if let Some(idx) = crate::ladder::parse_vote_index(text) { + self.shared.ladder.lock().unwrap().on_vote(crate::platform::unix_now_f(), idx, LADDER_GAP_S); + } } else if text.contains("worker could not prepare") || text.contains(" prepare-failed ") { self.st().program.prepared = false; self.shared.event("error", &format!("{card_name}: the worker could not compile the next program; it compiles at the boundary")); diff --git a/app/igneum-app/src/ladder.rs b/app/igneum-app/src/ladder.rs new file mode 100644 index 000000000..99486799c --- /dev/null +++ b/app/igneum-app/src/ladder.rs @@ -0,0 +1,514 @@ +//! The miner's first month, as this machine records it (miner-ui-5, mission item 6, 7 October 2026). The ladder's +//! rungs are chain facts (src/chainfacts.rs reads them from `getFinalityWeights` and `getFinalityCheckpoints`); this +//! module keeps what only this machine knows: when its first block came and on which card, the blocks it found per day +//! (the days-mined ring), every checkpoint its miners signed and which of those locked (the signing streak), the shards +//! its cards were paid for, and the block card to show (the first block, every 100th, 1,000th and 10,000th, the first +//! on a new card). Persisted as `/ladder.json`; pure, no clock of its own (every call takes `now`). +//! +//! A block is matched to its hash by the nonce: the miner prints `ACCEPTED block nonce=0x..` for ours, the node prints +//! `PoW accepted by .. (daa N, .., nonce 0x..)` for every block it validates, ours and relayed alike, so the +//! nonce is the join and nothing else is. +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +pub const DAY_S: f64 = 86_400.0; +/// days kept in the ring (the 30-day window plus a month of slack for the view) +const KEEP_DAYS: u64 = 60; +const KEEP_BLOCKS: usize = 30; +const KEEP_SHARDS: usize = 50; +const KEEP_PENDING: usize = 400; +const KEEP_SIGNED: usize = 400; +/// the block counts that raise a card (then every 10,000th) +pub const MILESTONES: [u64; 4] = [1, 100, 1_000, 10_000]; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct DayBlocks { + /// unix day number (unix seconds / 86,400, UTC) + pub day: u64, + pub blocks: u64, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct BlockFound { + pub hash: String, + pub daa: u64, + pub nonce: String, + pub at: f64, + pub card: String, + pub card_name: String, + /// this machine's lifetime count at that block (1 = the first) + pub count: u64, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct ShardPaid { + pub block: u64, + pub shard: u64, + /// wei as a decimal string (u128 does not survive every JSON reader) + pub wei: String, + pub at: f64, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct Milestone { + /// first | hundred | thousand | ten_thousand | card + pub kind: String, + pub count: u64, + pub card: String, + pub card_name: String, + pub hash: String, + pub daa: u64, + pub at: f64, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct Ladder { + #[serde(default)] + pub first_block_at: f64, + #[serde(default)] + pub first_block_card: String, + #[serde(default)] + pub first_block_hash: String, + #[serde(default)] + pub first_block_daa: u64, + #[serde(default)] + pub days: Vec, + /// card key -> unix s of its first block + #[serde(default)] + pub cards_first: BTreeMap, + #[serde(default)] + pub blocks: Vec, + #[serde(default)] + pub votes_signed: u64, + #[serde(default)] + pub last_vote_index: u64, + #[serde(default)] + pub last_vote_at: f64, + /// checkpoints signed and not yet seen locked + #[serde(default)] + pub signed_open: Vec, + #[serde(default)] + pub last_signed_locked: u64, + #[serde(default)] + pub signed_locked_count: u64, + /// when the unbroken run of votes began (0 = no vote yet) + #[serde(default)] + pub streak_since: f64, + #[serde(default)] + pub shards: Vec, + #[serde(default)] + pub milestone: Option, + /// the first-hour timeline's marks: when this install's node first synced and a card first mined (0 = not yet) + #[serde(default)] + pub first_synced_at: f64, + #[serde(default)] + pub first_mining_at: f64, + /// node-accepted blocks waiting for the miner's own ACCEPTED line (nonce -> hash, daa), and the reverse case + #[serde(default)] + pending: Vec<(String, String, u64)>, +} + +/// What the dashboard reads (api/state.ladder): the record above summarised at `now`. +#[derive(Clone, Debug, Default, Serialize)] +pub struct LadderState { + pub first_block_at: f64, + pub first_block_card: String, + pub first_block_hash: String, + pub first_block_daa: u64, + /// distinct UTC days with at least one block in the last 30 days + pub days_mined_30: u64, + pub blocks_30d: u64, + pub blocks_today: u64, + pub days: Vec, + pub cards_first: BTreeMap, + pub blocks: Vec, + pub votes_signed: u64, + pub last_vote_index: u64, + pub last_vote_at: f64, + pub last_signed_locked: u64, + pub signed_locked_count: u64, + /// seconds of unbroken signing (0 = none) + pub streak_s: f64, + pub shards: Vec, + pub milestone: Option, + pub first_synced_at: f64, + pub first_mining_at: f64, +} + +pub fn day_of(unix: f64) -> u64 { + (unix.max(0.0) / DAY_S).floor() as u64 +} + +impl Ladder { + pub fn load(path: &std::path::Path) -> Ladder { + std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default() + } + pub fn save(&self, path: &std::path::Path) { + if let Some(d) = path.parent() { + let _ = std::fs::create_dir_all(d); + } + if let Ok(t) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(path, t); + } + } + + /// The node validated a block (its own log line): remembered by nonce until the miner's line claims it. + pub fn on_node_accepted(&mut self, hash: &str, daa: u64, nonce: &str) { + let nonce = norm_nonce(nonce); + if nonce.is_empty() || hash.is_empty() { + return; + } + // a block of ours whose miner line came first: fill the hash in now + if let Some(b) = self.blocks.iter_mut().find(|b| b.hash.is_empty() && b.nonce == nonce) { + b.hash = hash.to_string(); + b.daa = daa; + if self.first_block_hash.is_empty() && self.first_block_at == b.at { + self.first_block_hash = hash.to_string(); + self.first_block_daa = daa; + } + if let Some(m) = self.milestone.as_mut() { + if m.hash.is_empty() && m.at == b.at { + m.hash = hash.to_string(); + m.daa = daa; + } + } + return; + } + self.pending.push((nonce, hash.to_string(), daa)); + if self.pending.len() > KEEP_PENDING { + let n = self.pending.len() - KEEP_PENDING; + self.pending.drain(0..n); + } + } + + /// The miner's own ACCEPTED line: `total` is this machine's lifetime count including this block. Returns the + /// milestone this block raised, if any. + pub fn on_block(&mut self, now: f64, card: &str, card_name: &str, nonce: &str, total: u64) -> Option { + let nonce = norm_nonce(nonce); + let (hash, daa) = match self.pending.iter().position(|p| !nonce.is_empty() && p.0 == nonce) { + Some(i) => { + let p = self.pending.remove(i); + (p.1, p.2) + } + None => (String::new(), 0), + }; + let day = day_of(now); + match self.days.iter_mut().find(|d| d.day == day) { + Some(d) => d.blocks += 1, + None => self.days.push(DayBlocks { day, blocks: 1 }), + } + self.days.sort_by_key(|d| d.day); + if self.days.len() > KEEP_DAYS as usize { + let n = self.days.len() - KEEP_DAYS as usize; + self.days.drain(0..n); + } + if self.first_block_at == 0.0 { + self.first_block_at = now; + self.first_block_card = card.to_string(); + self.first_block_hash = hash.clone(); + self.first_block_daa = daa; + } + let new_card = !card.is_empty() && !self.cards_first.contains_key(card); + if new_card { + self.cards_first.insert(card.to_string(), now); + } + self.blocks.insert(0, BlockFound { hash: hash.clone(), daa, nonce, at: now, card: card.to_string(), card_name: card_name.to_string(), count: total }); + self.blocks.truncate(KEEP_BLOCKS); + let kind = if total == 1 { "first" } else if total == 100 { "hundred" } else if total == 1_000 { "thousand" } else if total >= 10_000 && total % 10_000 == 0 { "ten_thousand" } else if new_card { "card" } else { "" }; + if kind.is_empty() { + return None; + } + let m = Milestone { kind: kind.into(), count: total, card: card.into(), card_name: card_name.into(), hash, daa, at: now }; + self.milestone = Some(m.clone()); + Some(m) + } + + /// A `VOTE index=N` line from a miner: the streak holds while votes keep coming inside `gap_s`. + pub fn on_vote(&mut self, now: f64, index: u64, gap_s: f64) { + self.votes_signed += 1; + if self.streak_since == 0.0 || (self.last_vote_at > 0.0 && now - self.last_vote_at > gap_s) { + self.streak_since = now; + } + self.last_vote_at = now; + if index > self.last_vote_index { + self.last_vote_index = index; + } + if index > 0 && !self.signed_open.contains(&index) { + self.signed_open.push(index); + if self.signed_open.len() > KEEP_SIGNED { + let n = self.signed_open.len() - KEEP_SIGNED; + self.signed_open.drain(0..n); + } + } + } + + /// A `LOCK checkpoint N` line: every signed index at or under N is in a locked certificate now. + pub fn on_lock(&mut self, index: u64) { + let (locked, open): (Vec, Vec) = self.signed_open.iter().partition(|&&i| i <= index); + if let Some(&top) = locked.iter().max() { + if top > self.last_signed_locked { + self.last_signed_locked = top; + } + self.signed_locked_count += locked.len() as u64; + } + self.signed_open = open; + } + + /// The timeline's first-time marks ("synced", "mining"): set once, kept. Returns true when it was new. + pub fn mark(&mut self, what: &str, now: f64) -> bool { + let slot = match what { "synced" => &mut self.first_synced_at, "mining" => &mut self.first_mining_at, _ => return false }; + if *slot > 0.0 { return false; } + *slot = now; + true + } + + pub fn on_shard_paid(&mut self, now: f64, block: u64, shard: u64, wei: u128) { + if self.shards.iter().any(|s| s.block == block && s.shard == shard) { + return; + } + self.shards.insert(0, ShardPaid { block, shard, wei: wei.to_string(), at: now }); + self.shards.truncate(KEEP_SHARDS); + } + + /// Seconds of unbroken signing at `now` (0 when the last vote is older than `gap_s`). + pub fn streak_s(&self, now: f64, gap_s: f64) -> f64 { + if self.streak_since == 0.0 || self.last_vote_at == 0.0 || now - self.last_vote_at > gap_s { + return 0.0; + } + (now - self.streak_since).max(0.0) + } + + fn blocks_since(&self, from: f64) -> u64 { + // the day ring is daily; the finer windows read the block list and fall back to the ring + self.days.iter().filter(|d| (d.day as f64 + 1.0) * DAY_S > from).map(|d| d.blocks).sum() + } + + pub fn state(&self, now: f64, gap_s: f64) -> LadderState { + let today = day_of(now); + let window_from = now - 30.0 * DAY_S; + let days_mined_30 = self.days.iter().filter(|d| d.blocks > 0 && (d.day as f64 + 1.0) * DAY_S > window_from).count() as u64; + LadderState { + first_block_at: self.first_block_at, + first_block_card: self.first_block_card.clone(), + first_block_hash: self.first_block_hash.clone(), + first_block_daa: self.first_block_daa, + days_mined_30, + blocks_30d: self.blocks_since(window_from), + blocks_today: self.days.iter().find(|d| d.day == today).map(|d| d.blocks).unwrap_or(0), + days: self.days.iter().filter(|d| (d.day as f64 + 1.0) * DAY_S > window_from).cloned().collect(), + cards_first: self.cards_first.clone(), + blocks: self.blocks.iter().take(10).cloned().collect(), + votes_signed: self.votes_signed, + last_vote_index: self.last_vote_index, + last_vote_at: self.last_vote_at, + last_signed_locked: self.last_signed_locked, + signed_locked_count: self.signed_locked_count, + streak_s: self.streak_s(now, gap_s), + shards: self.shards.clone(), + milestone: self.milestone.clone(), + first_synced_at: self.first_synced_at, + first_mining_at: self.first_mining_at, + } + } +} + +/// The Activity line a milestone raises (the dashboard's EVENT_MAP reads these shapes). +pub fn milestone_event(m: &Milestone) -> String { + match m.kind.as_str() { + "first" => format!("first block: found by {} (block card)", m.card_name), + "hundred" => format!("block 100 of this machine: found by {} (block card)", m.card_name), + "thousand" => format!("block 1,000 of this machine: found by {} (block card)", m.card_name), + "ten_thousand" => format!("block {} of this machine: found by {} (block card)", with_commas(m.count), m.card_name), + _ => format!("first block on {} (block card)", m.card_name), + } +} + +fn with_commas(n: u64) -> String { + let s = n.to_string(); + let mut out = String::new(); + for (i, c) in s.chars().enumerate() { + if i > 0 && (s.len() - i) % 3 == 0 { out.push(','); } + out.push(c); + } + out +} + +fn norm_nonce(n: &str) -> String { + let t = n.trim().trim_start_matches("0x").trim_start_matches('0').to_ascii_lowercase(); + if t.is_empty() && !n.trim().is_empty() { "0".into() } else { t } +} + +/// `PoW accepted by (daa N, ..., nonce 0x..)` -> (hash, daa, nonce) +pub fn parse_node_accepted(text: &str) -> Option<(String, u64, String)> { + let rest = text.split("PoW accepted ").nth(1)?; + let hash = rest.split_whitespace().next()?.to_string(); + if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let daa = rest.split("(daa ").nth(1)?.split(|c: char| !c.is_ascii_digit()).next()?.parse::().ok()?; + let nonce = rest.split("nonce ").nth(1)?.trim_end_matches(')').split_whitespace().next()?.to_string(); + Some((hash, daa, nonce)) +} + +/// `... ACCEPTED block nonce=0x.. (...)` -> the nonce +pub fn parse_miner_accepted(text: &str) -> Option { + let rest = text.split("ACCEPTED block nonce=").nth(1)?; + Some(rest.split_whitespace().next()?.to_string()) +} + +/// `... VOTE index=N ...` -> N +pub fn parse_vote_index(text: &str) -> Option { + text.split("VOTE index=").nth(1)?.split(|c: char| !c.is_ascii_digit()).next()?.parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + const NODE: &str = "2026-10-04 20:08:38.486+00:00 [INFO ] PoW accepted 28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce by igneum-lottery-v2-bound (daa 38637, epoch seed c8b574fbbbd6ba93b34f8c9a81042b6970b140ffb24219e381d82b653673ee81, day 20730, nonce 0x267dd27200a91c80)"; + const MINER: &str = "1791140918.500 ACCEPTED block nonce=0x267dd27200a91c80 (gpu worker, cpu re-check ok, identity mac-01234567-1)"; + + #[test] + fn the_node_and_miner_lines_parse() { + let (h, daa, n) = parse_node_accepted(NODE).unwrap(); + assert_eq!(h, "28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce"); + assert_eq!(daa, 38637); + assert_eq!(n, "0x267dd27200a91c80"); + assert_eq!(parse_miner_accepted(MINER).unwrap(), "0x267dd27200a91c80"); + assert_eq!(parse_vote_index("1791140918.500 VOTE index=1240 checkpoint=abcd signed").unwrap(), 1240); + assert!(parse_node_accepted("PoW rejected 00 by x").is_none()); + } + + #[test] + fn the_first_block_is_matched_to_its_hash_by_nonce_and_raises_the_first_card() { + let mut l = Ladder::default(); + let (h, daa, n) = parse_node_accepted(NODE).unwrap(); + l.on_node_accepted(&h, daa, &n); + // a relayed block from another miner sits in pending too and never matches + l.on_node_accepted("ef21a8454b2f3fcaf6c32a8b9c959df936770edfdffff61c7198989d9431ea4b", 38643, "0x4bc93aba007eabaa"); + let m = l.on_block(1_791_140_918.5, "apple::Apple M5 Max", "Apple M5 Max", &parse_miner_accepted(MINER).unwrap(), 1).unwrap(); + assert_eq!(m.kind, "first"); + assert_eq!(m.hash, h); + assert_eq!(m.daa, 38637); + assert_eq!(l.first_block_hash, h); + assert_eq!(l.first_block_daa, 38637); + assert_eq!(l.first_block_card, "apple::Apple M5 Max"); + assert_eq!(l.blocks.len(), 1); + assert_eq!(l.pending.len(), 1, "the relayed block stays unmatched"); + let s = l.state(1_791_140_920.0, 600.0); + assert_eq!(s.days_mined_30, 1); + assert_eq!(s.blocks_30d, 1); + assert_eq!(s.blocks_today, 1); + } + + #[test] + fn a_miner_line_before_the_node_line_gets_its_hash_filled_in() { + let mut l = Ladder::default(); + l.on_block(100.0, "c", "Card", "0x267dd27200a91c80", 1); + assert_eq!(l.first_block_hash, ""); + let (h, daa, n) = parse_node_accepted(NODE).unwrap(); + l.on_node_accepted(&h, daa, &n); + assert_eq!(l.blocks[0].hash, h); + assert_eq!(l.first_block_hash, h); + assert_eq!(l.milestone.as_ref().unwrap().hash, h); + assert_eq!(l.milestone.as_ref().unwrap().daa, 38637); + } + + #[test] + fn the_cards_come_at_1_100_1000_10000_and_on_a_new_card() { + let mut l = Ladder::default(); + let mut kinds = vec![]; + for total in 1..=1_000u64 { + if let Some(m) = l.on_block(1000.0 + total as f64, "a", "A", "", total) { kinds.push((total, m.kind)); } + } + assert_eq!(kinds, vec![(1, "first".to_string()), (100, "hundred".to_string()), (1000, "thousand".to_string())]); + assert_eq!(l.on_block(5000.0, "b", "B", "", 1001).unwrap().kind, "card"); + assert!(l.on_block(5001.0, "b", "B", "", 1002).is_none()); + assert_eq!(l.on_block(5002.0, "a", "A", "", 10_000).unwrap().kind, "ten_thousand"); + assert_eq!(l.on_block(5003.0, "a", "A", "", 20_000).unwrap().kind, "ten_thousand"); + assert_eq!(l.blocks.len(), KEEP_BLOCKS); + } + + #[test] + fn days_mined_counts_distinct_days_inside_the_window_only() { + let mut l = Ladder::default(); + let now = 40.0 * DAY_S + 100.0; + // blocks on days 5, 20, 20, 39 and 40 (today); day 5 is outside the 30-day window + for (d, total) in [(5.0, 1), (20.0, 2), (20.0, 3), (39.0, 4), (40.0, 5)] { + l.on_block(d * DAY_S + 50.0, "a", "A", "", total); + } + let s = l.state(now, 600.0); + assert_eq!(s.days_mined_30, 3); + assert_eq!(s.blocks_30d, 4); + assert_eq!(s.blocks_today, 1); + assert_eq!(s.days.len(), 3); + } + + #[test] + fn the_signing_streak_holds_across_votes_and_breaks_on_a_gap() { + let mut l = Ladder::default(); + l.on_vote(1000.0, 10, 600.0); + l.on_vote(1030.0, 11, 600.0); + l.on_vote(1060.0, 12, 600.0); + assert_eq!(l.streak_s(1090.0, 600.0), 90.0); + assert_eq!(l.votes_signed, 3); + assert_eq!(l.last_vote_index, 12); + // the lock at 11 puts 10 and 11 in locked certificates; 12 stays open + l.on_lock(11); + assert_eq!(l.last_signed_locked, 11); + assert_eq!(l.signed_locked_count, 2); + assert_eq!(l.signed_open, vec![12]); + // a gap longer than the presence window ends the streak; the next vote starts a new one + assert_eq!(l.streak_s(1700.0, 600.0), 0.0); + l.on_vote(1700.0, 33, 600.0); + assert_eq!(l.streak_s(1730.0, 600.0), 30.0); + assert_eq!(l.state(1730.0, 600.0).streak_s, 30.0); + } + + #[test] + fn the_milestone_events_read_in_the_users_words() { + let m = |kind: &str, count: u64| Milestone { kind: kind.into(), count, card: "a".into(), card_name: "RTX 4070".into(), hash: String::new(), daa: 0, at: 0.0 }; + assert_eq!(milestone_event(&m("first", 1)), "first block: found by RTX 4070 (block card)"); + assert_eq!(milestone_event(&m("hundred", 100)), "block 100 of this machine: found by RTX 4070 (block card)"); + assert_eq!(milestone_event(&m("ten_thousand", 20_000)), "block 20,000 of this machine: found by RTX 4070 (block card)"); + assert_eq!(milestone_event(&m("card", 7)), "first block on RTX 4070 (block card)"); + } + + #[test] + fn the_timeline_marks_are_set_once() { + let mut l = Ladder::default(); + assert!(l.mark("synced", 10.0)); + assert!(!l.mark("synced", 20.0)); + assert!(l.mark("mining", 30.0)); + assert!(!l.mark("other", 40.0)); + let s = l.state(50.0, 600.0); + assert_eq!(s.first_synced_at, 10.0); + assert_eq!(s.first_mining_at, 30.0); + } + + #[test] + fn paid_shards_are_kept_once_each_newest_first() { + let mut l = Ladder::default(); + l.on_shard_paid(10.0, 1_284_117, 3, 1_150_000_000_000_000_000); + l.on_shard_paid(11.0, 1_284_117, 3, 1_150_000_000_000_000_000); + l.on_shard_paid(12.0, 1_284_120, 0, 2_000_000_000_000_000_000); + assert_eq!(l.shards.len(), 2); + assert_eq!(l.shards[0].block, 1_284_120); + assert_eq!(l.shards[1].wei, "1150000000000000000"); + } + + #[test] + fn the_record_survives_a_save_and_load() { + let mut l = Ladder::default(); + l.on_block(100.0, "a", "A", "0x1", 1); + l.on_vote(130.0, 5, 600.0); + let dir = std::env::temp_dir().join(format!("igneum-ladder-test-{}", std::process::id())); + let path = dir.join("ladder.json"); + l.save(&path); + let back = Ladder::load(&path); + assert_eq!(back.first_block_at, 100.0); + assert_eq!(back.votes_signed, 1); + assert_eq!(back.blocks, l.blocks); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(Ladder::load(&dir.join("missing.json")).votes_signed, 0); + } +} diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index b04ad5f2b..f2a40b61d 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -40,6 +40,9 @@ mod watchdog; mod live; mod extnode; mod merge; +mod ladder; +mod chainfacts; +mod card; use std::io::{BufRead, Write}; use std::sync::mpsc::channel; diff --git a/app/igneum-app/src/platform.rs b/app/igneum-app/src/platform.rs index 55bfd2fae..42e67793d 100644 --- a/app/igneum-app/src/platform.rs +++ b/app/igneum-app/src/platform.rs @@ -206,6 +206,17 @@ pub fn open_url(url: &str) { let _ = Command::new("xdg-open").arg(url).spawn(); } +/// Shows a file in the system's file browser (the saved block card): Finder with the file selected, Explorer with +/// the file selected, the folder on other systems. +pub fn reveal_file(path: &std::path::Path) { + #[cfg(target_os = "macos")] + let _ = Command::new(tool("open")).arg("-R").arg(path).spawn(); + #[cfg(windows)] + let _ = quiet(&mut Command::new(tool("explorer"))).arg(format!("/select,{}", path.display())).spawn(); + #[cfg(not(any(target_os = "macos", windows)))] + let _ = Command::new("xdg-open").arg(path.parent().unwrap_or(path)).spawn(); +} + /// Keeps the machine awake while the engine runs. macOS: caffeinate tied to this process. Windows: the execution state, /// which must be refreshed (call `keep_awake_tick` every minute). pub struct KeepAwake { diff --git a/app/igneum-app/src/prover.rs b/app/igneum-app/src/prover.rs index 1d2140bbc..cd67296ac 100644 --- a/app/igneum-app/src/prover.rs +++ b/app/igneum-app/src/prover.rs @@ -515,6 +515,7 @@ fn loop_forever(shared: Arc, bin_dir: PathBuf) { if paid { submitted.retain(|x| !(x.1 == h && x.2 == s)); shared.event("proving", &format!("block {n} shard {s} paid {} IGN", wei as f64 / 1e18)); + shared.ladder_shard_paid(n, s as u64, wei); set(&shared, |p| { p.paid += 1; p.paid_wei += wei; diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index 2be34c31a..75d0c1f6d 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -13,6 +13,8 @@ const CSS: &str = include_str!("../ui/app.css"); const JS: &str = include_str!("../ui/app.js"); const MARK: &str = include_str!("../ui/mark.svg"); const DAG_JS: &str = include_str!("../ui/live-dag.js"); +/// the renderer pack's proof visual (EMBER 02, the site lane's byte-identical copy; miner-ui-5) +const PROOF_JS: &str = include_str!("../ui/proof-core.js"); const COIN: &[u8] = include_bytes!("../../../brand/igneum-coin-1024.png"); const FONT_MONO_400: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-400.woff2"); const FONT_MONO_500: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-500.woff2"); @@ -176,6 +178,7 @@ fn handle(mut stream: TcpStream, shared: Arc) { ("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false), ("GET", "/mark.svg") => respond(&mut stream, 200, "image/svg+xml", MARK.as_bytes(), true), ("GET", "/live-dag.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", DAG_JS.as_bytes(), false), + ("GET", "/proof-core.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", PROOF_JS.as_bytes(), false), ("GET", "/coin.png") => respond(&mut stream, 200, "image/png", COIN, true), ("GET", "/fonts/IBMPlexMono-400.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_400, true), ("GET", "/fonts/IBMPlexMono-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_500, true), @@ -196,6 +199,13 @@ fn handle(mut stream: TcpStream, shared: Arc) { let ids: std::collections::HashSet = shared.state.lock().unwrap().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect(); json_resp(&mut stream, 200, crate::live::fetch(&live_api, window, &ids)); } + // miner-ui-5: the ladder's chain facts (src/chainfacts.rs): the node's getFinalityWeights through the local + // node when it answers igneum_getFinalityWeights, else the observer's relay; cached 10 s + ("GET", "/api/ladder") => { + let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page)); + let ids: Vec = shared.state.lock().unwrap().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect(); + json_resp(&mut stream, 200, crate::chainfacts::fetch(shared.runtime.evm_port(), &live_api, &ids)); + } ("GET", "/api/log") => { let after = query_param(&req.query, "after").and_then(|s| s.parse().ok()).unwrap_or(0u64); let limit = query_param(&req.query, "limit").and_then(|s| s.parse().ok()).unwrap_or(400usize).min(2000); @@ -293,7 +303,8 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result shared.set_prove(body.get("on").and_then(|v| v.as_bool()).unwrap_or(false)), "/api/prove/setup" => crate::prover::setup(shared), @@ -381,6 +392,18 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result", beside URL, STATE and EXIT) and the host writes a PNG of // what it shows to that path under /shots/, where a collect job's glob "shots/*.png" picks it up. // Read-only: nothing about mining changes. A caller without a host (a bare engine) gets the path and no file. + // miner-ui-5: the block card's PNG, drawn by the page on a canvas (no network call), written under + // /cards/ and the folder opened for the user. The body is {name, png: "data:image/png;base64,..."}. + "/api/card" => { + let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("block"); + let png = body.get("png").and_then(|v| v.as_str()).ok_or("png missing")?; + let bytes = crate::card::decode_data_url(png).ok_or("the png is not a base64 data URL")?; + let path = crate::card::card_path(&crate::platform::data_root(), name, crate::platform::unix_now()); + if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } + std::fs::write(&path, &bytes).map_err(|e| format!("could not write the card: {e}"))?; + if body.get("reveal").and_then(|v| v.as_bool()).unwrap_or(true) { crate::platform::reveal_file(&path); } + Ok(json!({ "ok": true, "path": path.display().to_string(), "bytes": bytes.len() })) + } "/api/shot" => { let name = body.get("name").and_then(|v| v.as_str()).unwrap_or(""); let path = shot_path(&crate::platform::data_root(), name, crate::platform::unix_now()); diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index aba451b40..e746ae3ef 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -330,6 +330,8 @@ pub struct SettingsState { pub dev_fee: bool, /// devnet only: the node trusts proof records without a verifier (`IGNEUM_PROOF_VERIFY=trust`) pub proof_verify_trust: bool, + /// miner-ui-5: the public address profile opt-in (settings.profile_public) + pub profile_public: bool, } /// One remote job this machine ran (the ledger entry), for the Settings history and the last-job strip. @@ -447,6 +449,8 @@ pub struct State { pub now: f64, pub quitting: bool, pub live_page: String, + /// miner-ui-5: this machine's own ladder record (src/ladder.rs), summarised at `now` + pub ladder: crate::ladder::LadderState, } /// Ring buffers behind the state: events (newest first in the JSON) and the log drawer. From 32793b686a00279fc297c67140aac0ddca00b561 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:05:52 +0000 Subject: [PATCH 2/5] miner-ui-5 (0.3.19): the ladder on every page, the count-up, the block card, Earnings in IGN first, EMBER 02 scene View: poisson, ignPerDay, ladderRungs (every threshold from the node's params: dust, weightWindow, presenceWindow), blockCard, earningsLines (IGN first, the typed price never fetched, the share and rank, IGN per kWh), cardIgnDay, timeline, profileWords; FIELDS names every number's RPC field for the title on hover. Overview: the Poisson count-up until the first block, the block card in place with Save the card (a canvas PNG, no network call), Copy the link and the explorer, the ladder strip. Earnings: the six lines, the ladder card, Your first hour. Prove: the shard card. Cards: IGN a day per card. Settings: Make my page public. The chain scene is the site lane's EMBER 02 pack (live-dag.js and proof-core.js byte-identical): the compact card fed by the page's own api/live read, Inspect opens the full scene with the block inspector in the site's words. ui-mock: scenarios firstwait, firstblock, ladder, api/ladder, api/card. view.test.mjs: a Devnet 2 key walks every rung; 47 UI tests. Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/ui/app.css | 104 +++++- app/igneum-app/ui/app.js | 418 ++++++++++++++++++++++-- app/igneum-app/ui/index.html | 99 +++++- app/igneum-app/ui/live-dag.js | 542 +++++++++++++++++++------------- app/igneum-app/ui/proof-core.js | 72 +++++ app/igneum-app/ui/view.test.mjs | 196 ++++++++++++ tools/ui-mock/server.mjs | 38 ++- 7 files changed, 1219 insertions(+), 250 deletions(-) create mode 100644 app/igneum-app/ui/proof-core.js diff --git a/app/igneum-app/ui/app.css b/app/igneum-app/ui/app.css index 2d0dcdcaa..996ad5373 100644 --- a/app/igneum-app/ui/app.css +++ b/app/igneum-app/ui/app.css @@ -24,13 +24,15 @@ --s-1:4px;--s-2:8px;--s-3:12px;--s-4:16px;--s-5:24px;--s-6:32px; --gutter:var(--s-6);--card-pad:var(--s-5);--card-r:16px;--row-r:12px;--gap:var(--s-4); --sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--head:'Unbounded',sans-serif; - --top:60px;--bottom:0px;--drawer-h:260px;--rail:196px} + --top:60px;--bottom:0px;--drawer-h:260px;--rail:196px; + /* the chain scene's inclusion colours (EMBER 02 live-dag.js reads them from :root) */ + --included:#3B7DD8;--excluded:#B0362B} @media (prefers-color-scheme:light){:root:not([data-theme="dark"]){ --obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF; - --ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F}} + --ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F;--included:#2A62B8;--excluded:#B0362B}} :root[data-theme="light"]{ --obsidian:#F4F1EC;--graphite:#FFFFFF;--row:#FAF8F5;--line:#E2DED8;--line-2:#CFCAC2;--ember:#E04A14;--ember-hi:#F2541B;--molten:#B8731F;--bone:#16161A;--ash:#6B6B70;--ink-2:#3C3C42;--ember-ink:#FFFFFF; - --ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F} + --ember-12:rgba(224,74,20,.1);--ember-40:rgba(224,74,20,.4);--molten-10:rgba(184,115,31,.1);--molten-40:rgba(184,115,31,.4);--nvidia:#3C9B2C;--rail-bg:#EFEBE4;--hover:rgba(0,0,0,.04);--top-bg:rgba(244,241,236,.88);--shadow:rgba(0,0,0,.18);--scrim:rgba(244,241,236,.72);--ok:#B8731F;--included:#2A62B8;--excluded:#B0362B} *{box-sizing:border-box} html,body{height:100%} body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:var(--t-md);line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none;font-variant-numeric:tabular-nums} @@ -367,6 +369,8 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)} .money-row:last-child{border-bottom:0} .money-row .v{font-family:var(--head);font-weight:700;font-size:var(--t-hero);line-height:1.1;white-space:nowrap;letter-spacing:-.01em;min-width:200px} .money-row .v.small{font-size:var(--t-num)} +.money-row .v.tiny{font-family:var(--sans);font-size:var(--t-lg);font-weight:600;white-space:normal;min-width:0;line-height:1.35} +@media (max-width:720px){.money-row .v{white-space:normal;min-width:0;font-size:var(--t-h2)}.money-row .v.small{font-size:var(--t-xl)}} .money-row .s{font-size:var(--t-base);color:var(--ash);min-width:0} /* prove */ @@ -692,3 +696,97 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)} .legend{gap:6px 12px} .rail-foot .nav.small{padding:6px 4px} } + +/* ---- miner-ui-5: the miner's first month: the count-up, the block card, the ladder strip, the rungs, the timeline ---- */ +.wait-card{background:var(--row);padding:16px 20px} +.wait-row{display:flex;align-items:center;gap:var(--s-4)} +.wait-ring{position:relative;width:52px;height:52px;flex:0 0 52px;display:inline-flex;align-items:center;justify-content:center} +.wait-ring svg{position:absolute;inset:0;width:52px;height:52px;transform:rotate(-90deg)} +.wait-ring .track{fill:none;stroke:var(--line);stroke-width:3} +.wait-ring .arc{fill:none;stroke:var(--molten);stroke-width:3;stroke-linecap:round;stroke-dasharray:97.4;stroke-dashoffset:97.4;transition:stroke-dashoffset .6s ease} +.wait-ring .pct{font-size:var(--t-xs);color:var(--molten)} +.wait-text .t{font-size:var(--t-md);color:var(--bone)} +.wait-text .s{font-size:var(--t-sm);color:var(--ash);margin-top:2px} +.block-card{position:relative;background:linear-gradient(135deg,var(--graphite),var(--row));border-color:var(--line-2);box-shadow:inset 0 1px 0 var(--ember),0 18px 50px var(--shadow);user-select:text;-webkit-user-select:text} +.bc-head{display:flex;align-items:center;gap:var(--s-3);margin-bottom:var(--s-2)} +.bc-when{margin-left:auto;font-size:var(--t-xs);color:var(--ash)} +.bc-close{position:static;margin-left:4px} +.bc-title{font-size:var(--t-h1);letter-spacing:-.02em;margin-bottom:var(--s-2)} +.bc-title.small{font-size:var(--t-h2)} +.bc-line{font-size:var(--t-lg);color:var(--ink-2);margin:2px 0} +.bc-line.dim{color:var(--ash);font-size:var(--t-md)} +.bc-hash{font-size:var(--t-sm);color:var(--ash);margin:8px 0 12px;word-break:break-all} +.shard-card{box-shadow:inset 0 1px 0 var(--nvidia)} +.ladder-strip{padding:14px 20px} +.ls-rungs{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px} +.ls-rung{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.04em;color:var(--ash);padding:4px 10px 4px 6px;border:1px solid var(--line);border-radius:999px;white-space:nowrap} +.ls-rung i{width:8px;height:8px;border-radius:50%;background:var(--line-2);display:inline-block} +.ls-rung.done{color:var(--molten);border-color:var(--molten-40)} +.ls-rung.done i{background:var(--molten)} +.ls-rung.now{color:var(--bone);border-color:var(--ember-40)} +.ls-rung.now i{background:var(--ember);box-shadow:0 0 0 3px var(--ember-12)} +.ls-rung.off{opacity:.55} +.ls-line{display:flex;align-items:baseline;gap:var(--s-3);flex-wrap:wrap} +.ls-line .t{font-size:var(--t-md);color:var(--bone);font-weight:500} +.ls-line .s{font-size:var(--t-sm);min-width:0} +.ls-line .btn{margin-left:auto} +.rungs{list-style:none;margin:var(--s-3) 0 0;padding:0;display:flex;flex-direction:column} +.rung{display:grid;grid-template-columns:22px 1fr;gap:var(--s-3);padding:10px 0;border-bottom:1px solid var(--line)} +.rung:last-child{border-bottom:0} +.rg-dot{width:12px;height:12px;border-radius:50%;background:var(--line-2);margin-top:5px;border:2px solid transparent;position:relative} +.rung.done .rg-dot{background:var(--molten)} +.rung.now .rg-dot{background:var(--ember);box-shadow:0 0 0 4px var(--ember-12)} +.rung.off .rg-dot{background:transparent;border-color:var(--line-2)} +.rg-body{display:flex;flex-direction:column;gap:2px;min-width:0} +.rg-name{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash)} +.rg-line{font-size:var(--t-lg);color:var(--bone)} +.rung.done .rg-line{color:var(--molten)} +.rung.next .rg-line,.rung.off .rg-line{color:var(--ash)} +.rg-sub{font-size:var(--t-sm);color:var(--ash);line-height:1.45} +.rung .bar{display:block;height:3px;border-radius:2px;background:var(--line);overflow:hidden;margin-top:6px;max-width:320px} +.rung .bar b{display:block;height:100%;background:var(--ember)} +.timeline{list-style:none;margin:0;padding:0;display:flex;flex-direction:column} +.timeline li{display:grid;grid-template-columns:140px auto 1fr;gap:var(--s-3);align-items:baseline;padding:6px 0;border-bottom:1px solid var(--line);color:var(--ash);font-size:var(--t-md)} +.timeline li:last-child{border-bottom:0} +.timeline li.done{color:var(--bone)} +.timeline .tl-at{font-size:var(--t-sm);color:var(--molten);white-space:nowrap} +.timeline .tl-note{font-size:var(--t-sm);min-width:0} +.money-row.price-row .row{gap:6px;align-items:center} +.money-row .s+.s::before{content:"\00b7";margin-right:var(--s-2);color:var(--line-2)} +.num.ign .v{color:var(--molten)} +@media (max-width:720px){.bc-title{font-size:var(--t-h2)}.timeline li{grid-template-columns:110px auto 1fr}.ls-line .btn{margin-left:0}} + +/* ---- the full chain scene with the block inspector (EMBER 02; the words of the site's /live) ---- */ +.chain-full{margin-top:var(--s-3);border-top:1px solid var(--line);padding-top:var(--s-3)} +.cf-bar{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);flex-wrap:wrap;margin-bottom:var(--s-3)} +.cf-right{display:inline-flex;align-items:center;gap:var(--s-2)} +.zoom{display:inline-flex;align-items:center;border:1px solid var(--line);border-radius:6px;overflow:hidden} +.zoom button{background:transparent;border:0;color:var(--ink-2);font:400 13px/1 var(--mono);padding:7px 11px;cursor:pointer} +.zoom button:hover{background:var(--hover)} +.zoom span{font-size:var(--t-xs);color:var(--ash);padding:0 6px;min-width:44px;text-align:center} +.cf-graph{display:grid;grid-template-columns:minmax(0,1fr) 250px;gap:var(--s-4)} +.cf-scene{position:relative;min-width:0} +.cf-scene canvas{display:block;width:100%;height:360px;border-radius:10px;background:var(--row);border:1px solid var(--line)} +.cf-foot{margin-top:8px;font-size:var(--t-xs);color:var(--ash)} +.inspector{min-width:0;font-size:var(--t-base);border-left:1px solid var(--line);padding-left:var(--s-4)} +.inspector .ih{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:10px} +.chip{font-family:var(--mono);font-size:var(--t-xs);color:var(--ash);border:1px solid var(--line);border-radius:4px;padding:4px 8px;white-space:nowrap} +.ititle{font-family:var(--sans);font-weight:500;font-size:20px;line-height:1.2;color:var(--bone);margin:6px 0 4px;letter-spacing:-.01em} +.ihash{font-size:var(--t-xs);color:var(--ash);overflow-wrap:anywhere;margin-bottom:12px;user-select:text;-webkit-user-select:text} +.proof-scene{height:180px;border:1px solid var(--line);border-radius:6px;background:var(--row);margin:10px 0;overflow:hidden} +.proof-scene canvas{display:block;width:100%;height:100%} +.proof-head{display:flex;justify-content:space-between;align-items:baseline;font-weight:500;color:var(--bone);margin-bottom:8px} +.track{display:flex;gap:3px;height:3px;margin-bottom:10px} +.track i{flex:1 1 0;background:var(--line);border-radius:2px} +.track i.proving{background:var(--ember)}.track i.verified{background:var(--bone)}.track i.paid{background:var(--molten)} +.shards{list-style:none;margin:0 0 12px;padding:0;font-size:var(--t-xs);line-height:1.7;max-height:120px;overflow:auto} +.shards li{display:flex;justify-content:space-between;gap:8px;color:var(--ink-2)} +.shards li::before{content:"\25CF";font-size:7px;margin-right:6px;color:var(--line-2)} +.shards li.proving,.shards li.proving::before{color:var(--ember)}.shards li.verified,.shards li.verified::before{color:var(--bone)}.shards li.paid,.shards li.paid::before{color:var(--molten)} +.shards li.muted{color:var(--ash)} +.insp{display:grid;grid-template-columns:auto minmax(0,1fr);gap:0 10px;margin:0;border-top:1px solid var(--line);padding-top:6px;font-size:var(--t-xs)} +.insp dt{color:var(--ash);padding:5px 0} +.insp dd{margin:0;color:var(--bone);text-align:right;padding:5px 0;overflow-wrap:anywhere;min-width:0} +.inspector .note{font-size:var(--t-sm);margin-top:12px;padding-top:10px;border-top:1px solid var(--line)} +.inspector .top-gap{margin-top:8px} +@media (max-width:900px){.cf-graph{grid-template-columns:1fr}.inspector{border-left:0;padding-left:0;border-top:1px solid var(--line);padding-top:var(--s-3)}} diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index 2ecb21056..c4ca8c459 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -597,6 +597,11 @@ var View = (function () { [/^restarting the node with the new consensus parameters/, function () { return 'Restarting the node with the new rule settings'; }], [/^kernel tuning from the signed manifest/, function () { return 'Tuning starting points read from the team'; }], [/^block accepted by the node \((.+)\)/, function (m) { return 'Block found by ' + m[1]; }], + [/^first block: found by (.+) \(block card\)/, function (m) { return 'Your first block, found by ' + m[1]; }], + [/^block ([\d,]+) of this machine: found by (.+) \(block card\)/, function (m) { return 'Block ' + m[1] + ' of this machine, found by ' + m[2]; }], + [/^first block on (.+) \(block card\)/, function (m) { return 'First block on ' + m[1]; }], + [/^public profile on:/, function () { return 'Your address page is public'; }], + [/^public profile off:/, function () { return 'Your address page is unlisted'; }], [/^igneumd exited with code (\d+) after (\d+) s; restarting/, function (m) { return 'The node stopped (code ' + m[1] + '). Restarting'; }], [/^igneumd exited at once \(code (\d+)\)/, function (m) { return 'The node stopped at once (code ' + m[1] + ')'; }], [/^igneumd could not start/, function () { return 'The node could not start'; }], @@ -772,10 +777,173 @@ var View = (function () { if (n.source === 'none') return { text: 'no node', sub: n.port_note || n.mode_reason || 'the ports are taken by a node on other rules' }; return { text: '', sub: '' }; } + // ---- miner-ui-5 (7 October 2026): the miner's first month. The Poisson count-up before the first block, the block + // card, Earnings in IGN first with a typed price, the ladder rungs as chain facts, the first-hour timeline. + // Every number names its RPC field on hover: FIELDS holds the one sentence per number, the renderers put it in + // the title attribute. `chain` is GET api/ladder (src/chainfacts.rs): the node's getFinalityWeights table with + // this machine's keys ranked, the params, the locked index and the public /api/stats numbers. ---- + var FIELDS = { + hashrate: '/api/stats hashrate: the node’s estimateNetworkHashesPerSecond over 1,000 blocks (H/s)', + reward: '/api/stats block_reward.miner_ign: the 80 producer points of E(daa) (spec 2.5) at the newest DAA score, after the launch ramp', + ramp: '/api/stats block_reward.ramp_factor: the launch ramp, 10% on day one to 100% at day 30', + blocks: 'getFinalityWeights keys[].blocks: blue blocks with your key in the weight window (params.weightWindow)', + voter: 'getFinalityWeights keys[].voter: blocks at or above params.dust', + dust: 'getFinalityWeights params.dust: the blocks a key needs in the window to vote', + window: 'getFinalityWeights params.weightWindow: the window in DAA steps (one a second)', + participation: 'getFinalityWeights keys[].participation: the share of the last params.presenceWindow checkpoints your key signed', + rank: 'getFinalityWeights keys[] sorted by blocks, your key’s place; "of" = keys_listed (the observer relays at most 64)', + voters: 'getFinalityWeights voters: keys at or above dust', + total: 'getFinalityWeights totalWeight: every key’s blocks in the window', + locked: 'getFinalityCheckpoints latestLockedIndex: the newest checkpoint locked by two thirds of all weight', + signed: 'this app’s miner log: VOTE index=N lines, read as locked once a LOCK checkpoint line reaches N; the per-key signer list is owed from the node (getFinalityCheckpoints checkpoints[].signers)', + shards: 'igneum_getProofRecords(block).paid[shard] with your payout address (src/prover.rs)', + found: 'this app: the miner’s ACCEPTED block lines, each matched to the node’s PoW accepted line by nonce (ladder.json)', + found_hour: 'this app: mining.found, the accepted blocks of the last hour', + days: 'this app: distinct UTC days with a block in the last 30 days (ladder.json); per-key days are owed from the node (getFinalityWeights keys[].daysMined)', + streak: 'this app: unbroken VOTE lines inside the presence window (ladder.json); the chain fact is keys[].participation', + price: 'your figure: typed on this machine, remembered here, never fetched; no exchange lists IGN', + watts: 'the cards’ power draw now (nvidia-smi, the AMD telemetry, or the engine’s mining.watts_total)', + share: 'your rate over /api/stats hashrate' + }; + function fmtIgn(v) { if (v === null || v === undefined || isNaN(v)) return ''; var a = Math.abs(v); return (a >= 1000 ? withCommas(v) : a >= 100 ? v.toFixed(1) : a >= 1 ? v.toFixed(2) : v.toFixed(4)) + ' IGN'; } + function pct(v, digits) { if (!(v >= 0) || !isFinite(v)) return ''; var p = v * 100; return (p >= 10 ? p.toFixed(1) : p >= 1 ? p.toFixed(2) : p >= 0.01 ? p.toFixed(3) : p.toPrecision(2)) + '%'; } + // "16 minutes" / "1.6 hours" / "16 hours" / "6.8 days" for an expected gap + function gapWords(s) { + if (!(s > 0) || !isFinite(s)) return ''; + if (s < 90) return Math.max(1, Math.round(s)) + ' seconds'; + if (s < 5400) return Math.round(s / 60) + ' minutes'; + if (s < 36 * 3600) { var h = s / 3600; return (h < 10 ? h.toFixed(1) : Math.round(h)) + ' hours'; } + var d = s / 86400; return (d < 10 ? d.toFixed(1) : Math.round(d)) + ' days'; + } + // "41 days" / "3 h 12 min" / "48 min" for a streak or an age + function spanWords(s) { s = Math.max(0, Math.round(s || 0)); if (s < 60) return s + ' s'; if (s < 3600) return Math.floor(s / 60) + ' min'; if (s < 86400) return Math.floor(s / 3600) + ' h ' + Math.floor(s % 3600 / 60) + ' min'; var d = Math.floor(s / 86400); return d + ' day' + (d === 1 ? '' : 's'); } + // the chain's numbers the lines need, with nulls where the reply has none: c = GET api/ladder + function chainNums(c) { + var ok = !!(c && c.ok), n = (ok && c.network) || {}, p = (ok && c.params) || {}; + return { ok: ok, hps: n.hashrate_hps > 0 ? n.hashrate_hps : null, minerIgn: n.miner_ign_per_block > 0 ? n.miner_ign_per_block : null, ramp: n.ramp_factor > 0 ? n.ramp_factor : null, bps: n.bps > 0 ? n.bps : 1, dust: p.dust > 0 ? p.dust : null, windowS: p.weight_window > 0 ? p.weight_window : null, presence: p.presence_window > 0 ? p.presence_window : null, interval: p.checkpoint_interval > 0 ? p.checkpoint_interval : 30, best: ok && c.best ? c.best : null, mine: (ok && c.mine) || [], listed: ok ? (c.keys_listed || 0) : 0, cap: ok ? (c.keys_cap || 0) : 0, voters: ok ? (c.voters || 0) : 0, locked: ok ? (c.latest_locked_index || 0) : 0, active: ok ? !!c.finality_active : false, source: ok ? c.source : '', stale: !ok || !!n.stale, error: !ok && c ? (c.error || '') : '' }; + } + // the Poisson count-up: a card at `mhs` on a network at `hps` finds a block every hps / (mhs * 1e6 * bps) seconds on + // average; after `elapsed` seconds the chance of at least one is 1 - exp(-elapsed / gap). {gap_s, gapText, chance, + // chanceText, line, pool, share} + function poisson(mhs, hps, elapsed, bps, fleet) { + if (!(mhs > 0) || !(hps > 0)) return null; + bps = bps > 0 ? bps : 1; + var share = mhs * 1e6 / hps, gap = 1 / (share * bps), chance = 1 - Math.exp(-Math.max(0, elapsed || 0) / gap); + var who = fleet ? 'Your cards find' : 'A card like yours finds'; + var line = who + ' a block about every ' + gapWords(gap) + ' on today’s network. Chance so far: ' + Math.round(chance * 100) + '%.'; + var pool = gap > 8 * 3600 ? 'Above an 8-hour gap a pool pays by the share, every minute. The pool setting arrives with pool-0.' : ''; + return { gap_s: gap, gapText: gapWords(gap), chance: chance, chanceText: Math.round(chance * 100) + '%', line: line, pool: pool, share: share }; + } + // IGN a day for a rate on a network: 86,400 blocks a day at one a second, your share of them, the producer's points of each + function ignPerDay(mhs, hps, minerIgn, bps) { if (!(mhs > 0) || !(hps > 0) || !(minerIgn > 0)) return null; bps = bps > 0 ? bps : 1; return 86400 * bps * (mhs * 1e6 / hps) * minerIgn; } + // the window in the user's words: "30 days" on mainnet, "2 hours" on the devnet (params.weightWindow in seconds) + function windowWords(windowS) { if (!(windowS > 0)) return { n: 30, unit: 'days', text: '30 days' }; if (windowS >= 86400) { var d = Math.round(windowS / 86400); return { n: d, unit: 'days', text: d + ' day' + (d === 1 ? '' : 's') }; } var h = Math.max(1, Math.round(windowS / 3600)); return { n: h, unit: 'hours', text: h + ' hour' + (h === 1 ? '' : 's') }; } + // the days (or hours on the devnet) this key has mined inside the window: the ladder's day ring, or the hours + // spanned by its blocks when the window is under a day + function daysIn(ladder, windowS, now) { + var w = windowWords(windowS); ladder = ladder || {}; + if (w.unit === 'days') return { have: Math.min(w.n, ladder.days_mined_30 || 0), of: w.n, unit: 'days' }; + var hours = 0; + if (ladder.first_block_at > 0 && now) hours = Math.min(w.n, Math.floor((now - ladder.first_block_at) / 3600) + ((ladder.blocks_today || 0) > 0 || (ladder.blocks_30d || 0) > 0 ? 1 : 0)); + return { have: Math.max(0, hours), of: w.n, unit: 'hours' }; + } + // the ladder's rungs for a state: s = state, chain = api/ladder, now = unix s. Each rung: {id, name, state (done | now | + // next | off), line, sub, field, progress (-1 or 0..1)}. The gate (mission 2.6): a key walks every rung on screen. + function ladderRungs(s, chain, now) { + s = s || {}; var m = s.mining || {}, ld = s.ladder || {}, pv = s.proving || {}, c = chainNums(chain), rungs = []; + var best = c.best, inTable = !!best, blocks = inTable ? best.blocks : 0, found = m.accepted_total || 0; + // 0. first block + var first = found > 0 || blocks > 0; + rungs.push({ id: 'first', name: 'First block', state: first ? 'done' : 'now', line: first ? 'First block' + (ld.first_block_at > 0 && now ? ' ' + ago(now - ld.first_block_at) : '') + (ld.first_block_card ? ', ' + cardName(s, ld.first_block_card) : '') + '.' : 'Waiting for your first block.', sub: inTable ? withCommas(blocks) + ' block' + (blocks === 1 ? '' : 's') + ' with your key in the window' : (found ? withCommas(found) + ' found by this machine' : ''), field: inTable ? FIELDS.blocks : FIELDS.found, progress: -1 }); + // 1. the vote + var dust = c.dust, voter = inTable && best.voter; + rungs.push({ id: 'vote', name: 'Your key has a vote', state: voter ? 'done' : first ? 'now' : 'next', line: voter ? 'Your key has a vote.' : (dust ? withCommas(blocks) + ' of ' + withCommas(dust) + ' blocks to a vote.' : 'A vote needs the dust line’s blocks in the window.'), sub: voter ? withCommas(blocks) + ' blocks in the window, the line is ' + withCommas(dust || 0) : (inTable ? 'your key is in the table' : c.ok ? 'your key is not in the table yet' : (c.error || 'the chain facts are not read yet')), field: FIELDS.voter + ' · ' + FIELDS.dust, progress: voter ? 1 : dust ? Math.min(1, blocks / dust) : -1 }); + // 2. the signature in a locked checkpoint + var signedIdx = ld.last_signed_locked || 0, part = inTable ? best.participation : 0; + rungs.push({ id: 'signed', name: 'Your signature is in a checkpoint', state: signedIdx > 0 ? 'done' : voter ? 'now' : 'next', line: signedIdx > 0 ? 'Your signature is in checkpoint ' + withCommas(signedIdx) + '.' : voter ? 'Signing: the next locked checkpoint carries your vote.' : 'Comes with the vote.', sub: signedIdx > 0 ? withCommas(ld.signed_locked_count || 0) + ' signed and locked' + (inTable ? ' · presence ' + Math.round(part * 100) + '%' : '') + (c.locked ? ' · latest lock ' + withCommas(c.locked) : '') : (c.locked ? 'latest lock ' + withCommas(c.locked) : ''), field: FIELDS.signed + ' · ' + FIELDS.participation + ' · ' + FIELDS.locked, progress: -1 }); + // 3. the full window + var d = daysIn(ld, c.windowS, now), full = first && d.have >= d.of; + rungs.push({ id: 'window', name: 'Full window', state: full ? 'done' : first ? 'now' : 'next', line: full ? d.of + ' of ' + d.of + ' ' + d.unit + '. Full weight.' : d.have + ' of ' + d.of + ' ' + d.unit + '.', sub: 'weight is your blocks over the last ' + windowWords(c.windowS).text + '; a key that mined the whole window weighs what it mined', field: FIELDS.days + ' · ' + FIELDS.window, progress: d.of ? Math.min(1, d.have / d.of) : -1 }); + // 4. rank by weight + var rankLine = inTable ? 'Rank ' + withCommas(best.rank) + ' of ' + withCommas(c.listed) + ' keys.' : c.ok ? (c.cap ? 'Below the top ' + c.cap + ' keys.' : 'Not in the table yet.') : 'Rank: not read yet.'; + rungs.push({ id: 'rank', name: 'Rank by weight', state: inTable ? 'done' : first ? 'now' : 'next', line: rankLine, sub: c.ok ? withCommas(c.voters) + ' keys vote' + (c.cap && c.listed >= c.cap ? ' · the top ' + c.cap + ' are listed' : '') + ' · weight, never hashrate: a card that arrived today sits at the bottom' : '', field: FIELDS.rank + ' · ' + FIELDS.voters, progress: -1 }); + // 5. the signing streak + var streak = ld.streak_s || 0; + rungs.push({ id: 'streak', name: 'Signing streak', state: streak > 0 ? 'done' : voter ? 'now' : 'next', line: streak > 0 ? 'Signing 8 of 80 points, ' + spanWords(streak) + ' unbroken.' : 'The 8 signing points start with the vote.', sub: streak > 0 ? 'miss two hours of signing and the next blocks pay 72 until you sign again' + (inTable ? ' · presence ' + Math.round(part * 100) + '%' : '') : '', field: FIELDS.streak + ' · ' + FIELDS.participation, progress: -1 }); + // P. a paid shard + var sh = (ld.shards || [])[0], prove = !!(s.settings && s.settings.prove); + rungs.push({ id: 'shard', name: 'Your card proved a shard', state: sh ? 'done' : prove ? 'now' : 'off', line: sh ? 'Your card proved shard ' + sh.shard + ' of block ' + withCommas(sh.block) + '.' : prove ? 'Proving: the first paid shard lands here.' : 'Proving is off.', sub: sh ? fmtIgn(ign(sh.wei)) + ' · ' + withCommas(pv.paid || 0) + ' paid' : prove ? (pv.message || '') : 'The Prove page switches it on; 20 points of every block pay the provers', field: FIELDS.shards, progress: -1 }); + return rungs; + } + function cardName(s, key) { var l = ((s.mining || {}).cards || []).filter(function (c) { return c.key === key; }); return l.length ? l[0].name : ''; } + // the rung to put on the Overview strip: the first that is "now", else the last done + function currentRung(rungs) { for (var i = 0; i < rungs.length; i++) if (rungs[i].state === 'now') return rungs[i]; for (var j = rungs.length - 1; j >= 0; j--) if (rungs[j].state === 'done') return rungs[j]; return rungs[0]; } + // the block card: m = state.ladder.milestone, s = state, chain, now. {kind, eyebrow, title, when, cardLine, ignLine, + // rankLine, hash, url, fields} + function blockCard(m, s, chain, now) { + if (!m || !(m.count > 0)) return null; + var c = chainNums(chain), cards = (s.mining || {}).cards || [], cd = cards.filter(function (x) { return x.key === m.card; })[0] || {}; + var name = m.card_name || cd.name || 'your card', mhs = cd.hash_avg > 0 ? cd.hash_avg : cd.hash_now || 0; + var eyebrow = m.kind === 'first' ? 'your first block' : m.kind === 'hundred' ? 'block 100 of this machine' : m.kind === 'thousand' ? 'block 1,000 of this machine' : m.kind === 'ten_thousand' ? 'block ' + withCommas(m.count) + ' of this machine' : 'first block on ' + name; + var title = m.daa > 0 ? 'Block ' + withCommas(m.daa) + ' is yours.' : 'A block is yours.'; + var share = mhs > 0 && c.hps ? ' · ' + pct(mhs * 1e6 / c.hps) + ' of the network' : ''; + var cardLine = name + (mhs > 0 ? ' · ' + (mhs >= 100 ? mhs.toFixed(0) : mhs.toFixed(1)) + ' MH/s' : '') + share; + var addr = (s.address || {}).display || ''; + var ignLine = c.minerIgn ? fmtIgn(c.minerIgn) + (addr ? ' to ' + shortHex(addr, 6, 4) : '') + ' (72 producer points, 8 for signing' + (c.ramp && c.ramp < 1 ? ', ramp ' + Math.round(c.ramp * 100) + '%' : '') + ')' : (addr ? 'Paid to ' + shortHex(addr, 6, 4) : ''); + var d = daysIn(s.ladder, c.windowS, now), rankLine = (c.best ? 'rank ' + withCommas(c.best.rank) + ' of ' + withCommas(c.listed) + ' keys' : 'rank: not in the table yet') + ' · ' + (d.unit === 'days' ? 'day ' : 'hour ') + Math.max(1, d.have) + ' of ' + d.of; + return { kind: m.kind, count: m.count, eyebrow: eyebrow, title: title, when: m.at, cardLine: cardLine, ignLine: ignLine, rankLine: rankLine, hash: m.hash || '', hashShort: m.hash ? shortHex(m.hash, 8, 6) : 'hash: waiting for the node’s line', url: m.hash ? 'https://igneum.network/block/' + m.hash : '', key: 'card:' + m.count + ':' + Math.floor(m.at || 0), fields: { card: FIELDS.found, ign: FIELDS.reward, rank: FIELDS.rank + ' · ' + FIELDS.days } }; + } + // Earnings (reinvent 3.4): IGN first, the window, a typed price, the share and rank, IGN per kWh. price = £ per IGN + // typed by the user (null = none), pence = electricity, now = unix s + function earningsLines(s, chain, price, pence, now) { + s = s || {}; var m = s.mining || {}, ld = s.ladder || {}, pv = s.proving || {}, c = chainNums(chain); + var hour = (m.found || []).length, mhs = m.hash_total || 0; + var measured = c.minerIgn ? hour * 24 * c.minerIgn : null, expected = ignPerDay(mhs, c.hps, c.minerIgn, c.bps); + var out = {}; + if (measured !== null && hour > 0) out.day = { text: fmtIgn(measured) + ' a day', sub: 'at your last hour’s rate (' + hour + ' block' + (hour === 1 ? '' : 's') + ', ' + fmtIgn(c.minerIgn) + ' each)' + (expected !== null ? ' · about ' + fmtIgn(expected) + ' expected at ' + (mhs >= 100 ? mhs.toFixed(0) : mhs.toFixed(1)) + ' MH/s' : ''), field: FIELDS.found_hour + ' × 24 × ' + FIELDS.reward, value: measured }; + else if (expected !== null) out.day = { text: 'about ' + fmtIgn(expected) + ' a day', sub: 'expected at ' + (mhs >= 100 ? mhs.toFixed(0) : mhs.toFixed(1)) + ' MH/s, ' + pct(mhs * 1e6 / c.hps) + ' of today’s network · no block in the last hour yet', field: FIELDS.share + ' × 86,400 × ' + FIELDS.reward, value: expected }; + else out.day = { text: mhs > 0 ? 'IGN a day: reading the network' : '0 IGN a day', sub: mhs > 0 ? (c.error || 'the network rate and the reward come from igneum.network/api/stats') : 'no card is mining', field: FIELDS.reward, value: null }; + var wblocks = c.best ? c.best.blocks : (ld.blocks_30d || 0), wign = c.minerIgn ? wblocks * c.minerIgn : null, ww = windowWords(c.windowS); + var shards = pv.paid || 0, shardIgn = pv.paid_wei && Number(pv.paid_wei) ? Number(pv.paid_wei) / 1e18 : 0; + out.window = { text: withCommas(wblocks) + ' block' + (wblocks === 1 ? '' : 's') + ' in ' + ww.text + (wign !== null ? ', ' + fmtIgn(wign) : ''), sub: (c.best ? 'with your key in the weight window' : 'found by this machine (the chain’s count once your key is in the table)') + (wign !== null ? ' · at today’s reward' : '') + ' · ' + shards + ' shard' + (shards === 1 ? '' : 's') + (shardIgn ? ', ' + fmtIgn(shardIgn) : ''), field: (c.best ? FIELDS.blocks : FIELDS.found) + ' · ' + FIELDS.shards, blocks: wblocks }; + var dayIgn = out.day.value; + out.price = price > 0 && dayIgn !== null ? { text: 'about ' + money(dayIgn * price) + ' a day', sub: 'at £' + price + ' per IGN, your figure · no exchange lists IGN', field: FIELDS.price, set: true } : { text: price > 0 ? 'at £' + price + ' per IGN' : 'Type a price', sub: price > 0 ? 'your figure; the IGN a day line fills in first' : 'a £ per IGN you choose; remembered on this machine, never fetched. Nothing is bought or sold on devnet', field: FIELDS.price, set: price > 0 }; + var share = mhs > 0 && c.hps ? pct(mhs * 1e6 / c.hps) : '', d = daysIn(ld, c.windowS, now); + var parts = []; + if (share) parts.push('your cards are ' + share + ' of the network'); + if (c.best) parts.push('weight rank ' + withCommas(c.best.rank) + ' of ' + withCommas(c.listed) + ' keys'); else if (c.ok) parts.push(c.cap ? 'below the top ' + c.cap + ' keys by weight' : 'not in the weight table yet'); + parts.push(d.have + ' of ' + d.of + ' ' + d.unit); + out.share = { text: parts.join(' · '), sub: c.source === 'observer' ? 'the weights through igneum.network’s observer until this node answers igneum_getFinalityWeights' : c.source === 'node' ? 'the weights from your node' : (c.error || 'reading'), field: FIELDS.share + ' · ' + FIELDS.rank + ' · ' + FIELDS.days }; + var watts = fleetWatts(m, m.cards), kwhDay = watts > 0 ? watts * 24 / 1000 : 0; + out.perKwh = dayIgn !== null && kwhDay > 0 ? { text: fmtIgn(dayIgn / kwhDay) + ' per kWh', sub: 'IGN a day over ' + kwhDay.toFixed(1) + ' kWh a day at ' + Math.round(watts) + ' W', field: FIELDS.watts } : null; + return out; + } + // the per-card IGN a day cell on the Cards page + function cardIgnDay(cd, chain) { var c = chainNums(chain); var v = ignPerDay(cd.hash_now, c.hps, c.minerIgn, c.bps); return v === null ? '' : fmtIgn(v).replace(' IGN', ''); } + // the first hour as this machine lived it: steps with unix times (0 = not yet), from the settings' install stamp, + // the ladder's marks and the engine's events + function timeline(s, now) { + s = s || {}; var ld = s.ladder || {}, st = s.settings || {}, n = s.node || {}, m = s.mining || {}; + var installed = s.installed_at > 0 ? s.installed_at : 0, started = s.uptime_s >= 0 && now ? now - s.uptime_s : 0; + var synced = ld.first_synced_at > 0 ? ld.first_synced_at : (n.synced && started ? started : 0); + var mining = ld.first_mining_at > 0 ? ld.first_mining_at : (m.state === 'mining' && started ? started : 0); + var steps = [ + { id: 'install', name: 'Installed', at: installed, note: installed ? '' : 'not recorded on this install' }, + { id: 'started', name: 'App started', at: started, note: '' }, + { id: 'synced', name: 'Node synced', at: synced, note: synced ? (ld.first_synced_at > 0 ? '' : 'the moment is not recorded; synced now') : 'syncing' }, + { id: 'mining', name: 'Card mining', at: mining, note: mining ? (ld.first_mining_at > 0 ? '' : 'the moment is not recorded; mining now') : 'waiting for the node' }, + { id: 'block', name: 'First block', at: ld.first_block_at || 0, note: ld.first_block_at ? '' : 'waiting' }, + { id: 'payout', name: 'First payout', at: ld.first_block_at || 0, note: ld.first_block_at ? 'the coinbase of the first block, yours when the block is blue' : 'the first block pays, seconds later' } + ]; + var base = installed || started; + steps.forEach(function (x) { x.rel = x.at > 0 && base > 0 && x.at >= base ? '+' + spanWords(x.at - base) : ''; }); + return { base: base, steps: steps, field: 'settings.installed_at, uptime_s, ladder.first_synced_at, ladder.first_mining_at, ladder.first_block_at' }; + } + function profileWords(on) { return on ? 'Your address page on igneum.network is public: your key ids, blocks in the window, weight rank, signing presence and card model are shown. The site reads it from the chain and from this switch.' : 'Off: igneum.network/address keeps your page unlisted (noindex). Nothing about this machine is published.'; } // the confirmations' words (in place, never a dialog) function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? Mining restarts.'; } function trustAsk(on) { return on ? 'Include proofs the node never checked? Devnet only. The node restarts.' : 'Check proofs again? The node restarts.'; } - return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk }; + return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords }; })(); /* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ---------- One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned @@ -908,6 +1076,7 @@ if (typeof document !== 'undefined') (function () { setText('page-title-text', meta.title); setText('page-sub', meta.sub); $('main').scrollTop = 0; if (state) { renderPage(state); if (page === 'overview') size(); } + if (typeof pollChain === 'function') pollChain(true); } $('rail-nav').addEventListener('click', function (e) { var b = e.target.closest('.nav[data-page]'); if (b) showPage(b.dataset.page); }); // the rail answers the arrow keys and Home/End, so the sections are reachable without a mouse @@ -1373,26 +1542,216 @@ if (typeof document !== 'undefined') (function () { } function requestDraw() { if (hiddenNow() || drawRaf) return; clearTimeout(drawTimer); drawTimer = null; drawRaf = requestAnimationFrame(frame); } document.addEventListener('visibilitychange', function () { - if (hiddenNow()) { clearTimeout(drawTimer); drawTimer = null; if (drawRaf) { cancelAnimationFrame(drawRaf); drawRaf = 0; } clearTimeout(logTimer); logTimer = null; } - else { poll(); requestDraw(); if (drawerOpen()) { pollLog(); scheduleLogPoll(); } } + if (hiddenNow()) { clearTimeout(drawTimer); drawTimer = null; if (drawRaf) { cancelAnimationFrame(drawRaf); drawRaf = 0; } clearTimeout(logTimer); logTimer = null; if (typeof liveTimer !== 'undefined') { clearTimeout(liveTimer); liveTimer = null; } } + else { poll(); requestDraw(); if (drawerOpen()) { pollLog(); scheduleLogPoll(); } if (typeof pollLive === 'function') pollLive(); } }); window.matchMedia('(prefers-color-scheme: light)').addEventListener('change', function () { size(); }); requestDraw(); - // ---------- the chain scene (live-dag.js, the site's module, copied in): the engine's own api/live, this machine's - // lane as "you"; until the engine serves it (owed) the module reports failed and the blocks strip draws instead ---------- - var dag = null, dagMode = ''; + // ---------- the chain scene (EMBER 02: live-dag.js and proof-core.js, the site lane's byte-identical copies) ---------- + // One owner of the feed: this page reads the engine's api/live every 2 s while the Overview shows and pushes the reply + // into the compact scene on the card and, once opened, the full scene with the block inspector (poll: false on both). + // "mine" is this machine's vote key ids (the 8 hex the feed names as b.miner), never the payout address. The fallback + // strip (this machine's own blocks) draws when the feed fails three times in a row. + var dag = null, dagFull = null, proof = null, dagMode = '', liveTimer = null, liveBusy = false, lastLive = null, liveFails = 0, fullOpen = false; + function mineFn(b) { if (!b) return false; if (b.miner === 'you') return true; if (!state) return false; return View.isMine(b.miner, state.mining.cards); } + function dagFallback(on, why) { + var st = on ? 'failed' : (lastLive ? 'live' : 'connecting'); + if (dagMode !== st) { dagMode = st; setText('dag-state', st === 'live' ? 'live' : st === 'failed' ? 'your blocks only' : 'connecting'); setText('dag-note', st === 'failed' ? (why || 'The chain feed is not answering. This is the last 10 minutes of your own blocks.') : (lastLive ? View.liveNote(lastLive) : '')); } + $('dag-live').hidden = on; $('dag-tip').hidden = true; $('dag-pause').hidden = on; $('dag-inspect').hidden = on; $('dag').hidden = !on; + if (on) { size(); requestDraw(); } + } function mountDag() { if (dag || typeof IgneumDag === 'undefined' || !$('dag-live')) return; try { - var mine = function (b) { if (!b) return false; if (b.miner === 'you') return true; if (!state) return false; return View.isMine(b.miner, state.mining.cards); }; - dag = IgneumDag.mount($('dag-live'), { compact: window.innerWidth < 720, window: 120, url: 'api/live', mine: mine, tooltip: $('dag-tip'), chip: $('dag-chip'), pauseButton: $('dag-pause'), onData: function (d) { var n = View.liveNote(d); if (n && dagMode !== 'failed') setText('dag-note', n); }, onState: function (st, why) { - var live = st === 'live' || st === 'stale'; - if (dagMode !== st) { dagMode = st; setText('dag-state', st === 'live' ? 'live' : st === 'stale' ? 'behind' : st === 'failed' ? 'your blocks only' : 'connecting'); setText('dag-note', st === 'failed' ? 'The full chain view needs the next update. This is the last 10 minutes of your own blocks.' : st === 'stale' ? (why || '') : ''); } - $('dag-live').hidden = !live; $('dag-tip').hidden = true; $('dag-pause').hidden = !live; $('dag').hidden = live; - if (!live) { size(); requestDraw(); } - } }); - } catch (e) { dag = null; $('dag-live').hidden = true; $('dag').hidden = false; size(); } + dag = IgneumDag.mount($('dag-live'), { compact: true, window: 60, poll: false, mine: mineFn, tooltip: $('dag-tip'), chip: $('dag-chip'), pauseButton: $('dag-pause'), + onSelect: function (b, why) { if (b && why !== 'update') openFull(b.hash); }, + onState: function (st, why) { if (dagMode === 'failed') return; if (st === 'live' || st === 'stale') { setText('dag-state', st === 'live' ? 'live' : 'behind'); setText('dag-note', st === 'stale' ? (why || '') : (lastLive ? View.liveNote(lastLive) : '')); } } }); + pollLive(); + } catch (e) { dag = null; dagFallback(true, 'The chain view could not start.'); } + } + function pollLive() { + clearTimeout(liveTimer); liveTimer = null; + if (phase !== 'dashboard' || hiddenNow() || !dag) return; + if (!liveBusy) { + liveBusy = true; + fetch('api/live?window=120', { cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (d) { + liveBusy = false; + if (!d || d.ok === false) { liveFails++; if (liveFails >= 3) dagFallback(true, d && d.error ? 'The chain feed says: ' + d.error + '. This is the last 10 minutes of your own blocks.' : ''); return; } + liveFails = 0; lastLive = d; + if (dagMode === 'failed') dagFallback(false); + if (dag) dag.push(d); if (dagFull) dagFull.push(d); + if (dagMode !== 'failed') setText('dag-note', View.liveNote(d)); + }).catch(function () { liveBusy = false; liveFails++; if (liveFails >= 3) dagFallback(true); }); + } + liveTimer = setTimeout(pollLive, 2000); + } + function openFull(hash) { + if (!fullOpen) { fullOpen = true; $('chain-full').hidden = false; $('dag-inspect').setAttribute('aria-expanded', 'true'); setText('dag-inspect', 'Close'); mountFull(); } + if (hash && dagFull) dagFull.select(hash, 'card'); + } + function closeFull() { fullOpen = false; $('chain-full').hidden = true; $('dag-inspect').setAttribute('aria-expanded', 'false'); setText('dag-inspect', 'Inspect'); if (dagFull) { dagFull.destroy(); dagFull = null; } if (proof) { proof.destroy(); proof = null; } uninspect(); } + function mountFull() { + if (dagFull || typeof IgneumDag === 'undefined') return; + if (typeof IgneumProof !== 'undefined' && !proof) proof = IgneumProof.mount($('proof')); + dagFull = IgneumDag.mount($('dag-full'), { poll: false, window: 90, mine: mineFn, tooltip: $('dag-tip-full'), chip: $('dag-chip-full'), pauseButton: $('dag-pause-full'), + onWindow: function (w) { setText('z-pct', w + ' s'); }, + onSelect: function (b) { if (b) inspect(b); else uninspect(); }, + onPause: function (p) { $('dag-follow').classList.toggle('on', !p); if (proof) proof.setPaused(p || dagFull.getState().state !== 'live'); }, + onFollow: function (f) { $('dag-follow').classList.toggle('on', f); }, + onState: function (st) { if (proof && dagFull) proof.setPaused(st !== 'live' || dagFull.getStats().paused); } }); + if (lastLive) dagFull.push(lastLive); + } + $('dag-inspect').addEventListener('click', function () { if (fullOpen) closeFull(); else openFull(); }); + $('dag-filter').addEventListener('click', function (e) { var b = e.target.closest('.seg-b'); if (!b) return; document.querySelectorAll('#dag-filter .seg-b').forEach(function (x) { var on = x === b; x.classList.toggle('on', on); x.setAttribute('aria-checked', on ? 'true' : 'false'); }); if (dagFull) dagFull.setFilter(b.dataset.filter); }); + $('dag-follow').addEventListener('click', function () { if (dagFull) { dagFull.setPaused(false); dagFull.follow(); } }); + $('z-in').addEventListener('click', function () { if (dagFull) dagFull.setWindow(dagFull.getWindow() - 15); }); + $('z-out').addEventListener('click', function () { if (dagFull) dagFull.setWindow(dagFull.getWindow() + 15); }); + $('insp-close').addEventListener('click', function () { if (dagFull) dagFull.select(null, 'clear'); uninspect(); }); + // the inspector: the site's /live words, from the module's selected record (kept fresh through onSelect(b, 'update')) + function fmtN(v) { return v === null || v === undefined ? 'pending' : withCommas(v); } + function hmsUtc(ms) { var d = new Date(ms); return String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0') + ':' + String(d.getUTCSeconds()).padStart(2, '0'); } + function inspect(b) { + $('insp-close').hidden = false; + var mineB = mineFn(b); + setText('i-kind', mineB ? 'your block' : b.locked ? 'checkpoint' : b.chain ? 'chain block' : b.color === 'red' ? 'excluded' : 'side block'); + setText('i-title', b.locked ? 'Checkpoint locked' : b.proven ? 'Proof complete' : (b.shards || []).some(function (x) { return x.state === 'proving'; }) ? 'Proof in progress' : 'Block observed'); + setText('i-hash', b.hash + (b.number ? ' · #' + fmtN(b.number) : '')); + var sh = b.shards || [], done = sh.filter(function (x) { return x.state === 'verified' || x.state === 'paid'; }).length; + setText('i-shard-count', sh.length ? done + ' / ' + sh.length + ' complete' : 'no shard plan yet'); + $('i-track').innerHTML = sh.map(function (x) { return ''; }).join(''); + $('i-shards').innerHTML = sh.length ? sh.map(function (x, i) { return '
  • Shard ' + String(i + 1).padStart(2, '0') + '' + esc(x.state.charAt(0).toUpperCase() + x.state.slice(1)) + '
  • '; }).join('') : '
  • ' + (b.chain ? 'No shard plan yet.' : 'Not a chain block: no shards.') + '
  • '; + setText('i-incl', (b.color === 'blue' ? 'Included' : b.color === 'red' ? 'Excluded' : 'Pending') + (b.chain ? ' / selected chain' : '')); + setText('i-miner', (b.miner === 'you' ? 'you' : b.miner) + (mineB && b.miner !== 'you' ? ' (you)' : '')); + setText('i-blue', fmtN(b.blue_score)); setText('i-daa', fmtN(b.daa)); + setText('i-parents', (b.parents || []).length + ' observed' + ((b.parents || []).length ? ': ' + b.parents.map(function (h) { return View.shortHex(h, 8, 6); }).join(', ') : '')); + setText('i-time', b.ts ? hmsUtc(b.ts) + ' UTC' : 'not set'); + var fn = lastLive && lastLive.finality, cp = null; if (fn && fn.checkpoints) fn.checkpoints.forEach(function (c) { if (b.locked && c.hash === b.hash) cp = c; }); + setText('i-cp', cp ? 'this block, #' + fmtN(cp.index) + (cp.fraction_total ? ', ' + Math.round(cp.fraction_total * 100) + '% of weight' : '') : 'not a checkpoint'); + setText('i-lock', b.final ? 'Final (reported)' : 'Not marked final'); + if (proof) proof.setBlock(b); + } + function uninspect() { + $('insp-close').hidden = true; setText('i-kind', 'select a block'); setText('i-title', 'Select a block'); setText('i-hash', 'Click the graph.'); setText('i-shard-count', 'not set'); $('i-track').innerHTML = ''; $('i-shards').innerHTML = '
  • No block selected.
  • '; + ['i-incl', 'i-miner', 'i-blue', 'i-daa', 'i-parents', 'i-time', 'i-cp', 'i-lock'].forEach(function (id) { setText(id, 'not set'); }); + if (proof) proof.setBlock(null); + } + // ---------- miner-ui-5: the miner's first month ---------- + // the chain facts (GET api/ladder, src/chainfacts.rs) every 15 s while the dashboard shows; `chain` feeds the + // Poisson line, the block card, Earnings and the ladder; every number names its field on hover (View.FIELDS) + var chain = null, chainAt = 0, chainBusy = false, miningSince = 0, cardDismissed = stored('igneum.card.dismissed') || '', cardSaving = false, chainSig = ''; + function pollChain(force) { + if (phase !== 'dashboard' || hiddenNow() || chainBusy) return; + if (!force && Date.now() - chainAt < 15000) return; + chainBusy = true; + api('api/ladder').then(function (r) { chain = r; chainAt = Date.now(); chainBusy = false; var sig = JSON.stringify(r); if (sig !== chainSig) { chainSig = sig; if (state) renderPage(state); } }).catch(function () { chainBusy = false; chainAt = Date.now(); }); + } + // £ per IGN, typed on this machine (Earnings), never fetched + function ignPrice() { var p = parseFloat(stored('igneum.ign_price') || ''); return p > 0 ? p : null; } + function saveIgnPrice() { + var p = parseFloat($('e-price-input').value); + if (!(p >= 0)) { toast('Type a price in pounds per IGN, for example 0.05'); return; } + store('igneum.ign_price', p > 0 ? p : null); toast(p > 0 ? 'Your price: £' + p + ' per IGN. It stays on this machine' : 'Price cleared'); + if (state) renderPage(state); + } + $('e-price-save').addEventListener('click', saveIgnPrice); + $('e-price-input').addEventListener('keydown', function (e) { if (e.key === 'Enter') saveIgnPrice(); }); + $('ls-more').addEventListener('click', function () { showPage('earnings'); }); + disclosure('tl-toggle', 'tl-details'); + $('s-profile').addEventListener('change', function () { var on = this.checked; api('api/settings', { profile_public: on }).then(function (r) { if (r.ok) toast(on ? 'Your page is public from the next site read' : 'Your page is unlisted again'); else { toast(r.error || 'could not change'); $('s-profile').checked = !on; } }).catch(function () { toast('This engine has no profile switch yet'); $('s-profile').checked = !on; }); }); + // the block card's buttons + $('bc-close').addEventListener('click', function () { cardDismissed = $('block-card').dataset.key || ''; store('igneum.card.dismissed', cardDismissed); $('block-card').hidden = true; }); + $('bc-open').addEventListener('click', function () { var u = $('block-card').dataset.url; if (u) api('api/open', { url: u }); else toast('The hash is not known yet'); }); + $('bc-copy').addEventListener('click', function () { var u = $('block-card').dataset.url; if (u) copyText(u, 'Link copied'); else toast('The hash is not known yet'); }); + $('bc-save').addEventListener('click', function () { + if (!state || cardSaving) return; + var m = View.blockCard(state.ladder.milestone, state, chain, state.now); if (!m) return; + cardSaving = true; setText('bc-saved', 'drawing'); + drawCard($('bc-canvas'), m, state).then(function (png) { + return api('api/card', { name: 'block-' + (m.count || 0), png: png }).then(function (r) { cardSaving = false; if (r.ok) { setText('bc-saved', 'saved: ' + r.path); toast('Card saved. The folder is open'); } else { setText('bc-saved', ''); toast(r.error || 'could not save'); } }); + }).catch(function (e) { cardSaving = false; setText('bc-saved', ''); toast('Could not draw the card: ' + (e && e.message || e)); }); + }); + // the PNG, 1200x630, drawn from the card's own words with the local fonts: no network call. The miner's object, + // never the project's number: the card, the rate, the IGN, the rank and the day, the hash and the link. + function drawCard(cv, m, s) { + var loads = ['900 72px Unbounded', '700 28px Unbounded', '500 30px "IBM Plex Sans"', '400 22px "IBM Plex Mono"', '500 18px "IBM Plex Mono"'].map(function (f) { return document.fonts && document.fonts.load ? document.fonts.load(f).catch(function () { }) : Promise.resolve(); }); + return Promise.all(loads).then(function () { + var W = 1200, H = 630, x = cv.getContext('2d'); + cv.width = W; cv.height = H; + x.fillStyle = '#0C0C0E'; x.fillRect(0, 0, W, H); + var g = x.createRadialGradient(W - 140, H + 60, 20, W - 140, H + 60, 620); g.addColorStop(0, 'rgba(255,179,92,.22)'); g.addColorStop(1, 'rgba(255,179,92,0)'); x.fillStyle = g; x.fillRect(0, 0, W, H); + x.fillStyle = '#F2541B'; x.fillRect(0, 0, 14, H); + // the mark (ui/mark.svg's two polygons) at 64 px + var pts = [[50, 4], [74, 34], [67, 58], [80, 54], [61, 96], [39, 96], [20, 54], [33, 58], [26, 34]], inner = [[50, 42], [59, 58], [50, 82], [41, 58]], ox = 72, oy = 56, k = 0.64; + x.fillStyle = '#F2541B'; x.beginPath(); pts.forEach(function (p, i) { x[i ? 'lineTo' : 'moveTo'](ox + p[0] * k, oy + p[1] * k); }); x.closePath(); x.fill(); + x.fillStyle = '#0C0C0E'; x.beginPath(); inner.forEach(function (p, i) { x[i ? 'lineTo' : 'moveTo'](ox + p[0] * k, oy + p[1] * k); }); x.closePath(); x.fill(); + x.fillStyle = '#F4F1EC'; x.font = '900 28px Unbounded'; x.textBaseline = 'alphabetic'; x.fillText('IGNEUM', 152, 100); + x.fillStyle = '#9A9A9E'; x.font = '500 18px "IBM Plex Mono"'; x.textAlign = 'right'; x.fillText(whenWords(m.when) + ' · Igneum Miner ' + (s.version || ''), W - 56, 100); x.textAlign = 'left'; + x.fillStyle = '#F2541B'; x.font = '500 20px "IBM Plex Mono"'; x.fillText(m.eyebrow.toUpperCase().split('').join(' '), 72, 196); + x.fillStyle = '#F4F1EC'; x.font = '900 72px Unbounded'; + var t = m.title.replace(/\.$/, ''), parts = t.split(' is yours'); + if (x.measureText(t).width <= W - 144) x.fillText(t, 72, 286); + else { x.fillText(parts[0], 72, 266); x.fillStyle = '#FFB35C'; x.fillText('is yours', 72, 346); } + var y = parts.length > 1 && x.measureText(t).width > W - 144 ? 420 : 370; + x.fillStyle = '#C9C7C2'; x.font = '500 30px "IBM Plex Sans"'; x.fillText(m.cardLine.split(' · ').slice(0, 2).join(' · '), 72, y); + x.fillStyle = '#FFB35C'; x.fillText(m.ignLine, 72, y + 50); + x.fillStyle = '#9A9A9E'; x.font = '500 26px "IBM Plex Sans"'; x.fillText(m.rankLine, 72, y + 98); + x.fillStyle = '#9A9A9E'; x.font = '400 22px "IBM Plex Mono"'; + var ids = ((s.mining || {}).cards || []).filter(function (c) { return c.key === (s.ladder.milestone || {}).card; }).map(function (c) { return (c.ids || [])[0]; }).filter(Boolean); + x.fillText((ids.length ? 'Miner key ' + ids[0] + ' · ' : '') + 'hash ' + (m.hash ? m.hashShort : 'pending'), 72, 560); + x.fillStyle = '#F4F1EC'; x.font = '400 20px "IBM Plex Mono"'; x.fillText(m.url ? m.url.replace(/^https:\/\//, '') : 'igneum.network/explorer', 72, 598); + return cv.toDataURL('image/png'); + }); + } + function whenWords(t) { if (!(t > 0)) return ''; var d = new Date(t * 1000); return d.getDate() + ' ' + ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][d.getMonth()] + ' ' + d.getFullYear() + ' ' + clock(t); } + var rungsSig = '', stripSig = ''; + function rungHtml(r) { + return '
  • ' + esc(r.name) + '' + esc(r.line) + '' + (r.sub ? '' + esc(r.sub) + '' : '') + (r.progress >= 0 && r.state !== 'done' ? '' : '') + '
  • '; + } + // the Overview's first-month surfaces: the count-up or the block card, then the ladder strip + function renderFirstMonth(s) { + var m = s.mining, now = s.now, ld = s.ladder || {}; + if (m.state === 'mining' && m.hash_total > 0) { if (!miningSince) miningSince = now; } else miningSince = 0; + var c = View.chainNums(chain), p = View.poisson(m.hash_total, c.hps, miningSince ? now - miningSince : 0, c.bps, (m.cards || []).filter(function (x) { return x.state === 'mining'; }).length > 1); + var waiting = !(m.accepted_total > 0) && m.state === 'mining'; + setHidden('first-wait', !waiting); + if (waiting) { + if (p) { setText('first-wait-line', p.line); setText('first-wait-sub', p.pool || 'The chance counts up from the first job. The line gives way to the block card.'); setText('first-wait-pct', p.chanceText); $('first-wait-arc').style.strokeDashoffset = (97.4 * (1 - p.chance)).toFixed(1); } + else { setText('first-wait-line', 'Waiting for your first block.'); setText('first-wait-sub', m.hash_total > 0 ? (c.error || 'The expected gap needs the network rate from igneum.network/api/stats.') : 'The cards are starting.'); setText('first-wait-pct', ''); $('first-wait-arc').style.strokeDashoffset = '97.4'; } + $('first-wait').title = View.FIELDS.share + ' · ' + View.FIELDS.hashrate + ' · gap = 1 / (share × blocks per second); chance = 1 − e^(−elapsed / gap)'; + } + var bc = View.blockCard(ld.milestone, s, chain, now), card = $('block-card'); + var show = !!bc && bc.key !== cardDismissed; + setHidden('block-card', !show); + if (show) { + card.dataset.key = bc.key; card.dataset.url = bc.url; + setText('bc-eyebrow', bc.eyebrow); setText('bc-when', bc.when > 0 ? clock(bc.when) : ''); setText('bc-title', bc.title); + setText('bc-card', bc.cardLine); $('bc-card').title = bc.fields.card; + setText('bc-ign', bc.ignLine); $('bc-ign').title = bc.fields.ign; + setText('bc-rank', bc.rankLine); $('bc-rank').title = bc.fields.rank; + setText('bc-hash', 'hash ' + bc.hashShort); $('bc-hash').title = bc.hash; + $('bc-open').disabled = !bc.url; $('bc-copy').disabled = !bc.url; + } + var rungs = View.ladderRungs(s, chain, now), cur = View.currentRung(rungs), hasCards = (m.cards || []).length > 0; + setHidden('ladder-strip', !hasCards); + var sig = JSON.stringify([rungs.map(function (r) { return r.state; }), cur.id, cur.line, cur.sub]); + if (sig !== stripSig) { + stripSig = sig; + $('ls-rungs').innerHTML = rungs.map(function (r) { return '' + esc(r.name) + ''; }).join(''); + setText('ls-line', cur.line); setText('ls-sub', cur.sub); $('ls-line').title = cur.field; + } + } + function renderLadderCard(s) { + var rungs = View.ladderRungs(s, chain, s.now), c = View.chainNums(chain); + setText('ladder-source', c.source === 'node' ? 'from your node' : c.source === 'observer' ? 'through the observer' : (c.error ? 'not read' : 'reading')); + $('ladder-source').title = c.source === 'observer' ? 'getFinalityWeights relayed by igneum.network/api/live until this node answers igneum_getFinalityWeights; keys cut to 64' : c.source === 'node' ? 'igneum_getFinalityWeights on this machine’s node' : (c.error || ''); + var sig = JSON.stringify(rungs); + if (sig !== rungsSig) { rungsSig = sig; $('rungs').innerHTML = rungs.map(rungHtml).join(''); } + var tl = View.timeline(s, s.now), done = tl.steps.filter(function (x) { return x.at > 0; }).length; + setText('tl-sum', done + ' of ' + tl.steps.length + (tl.steps[4].rel ? ' · first block ' + tl.steps[4].rel : '')); + $('tl-steps').innerHTML = tl.steps.map(function (x) { return '
  • ' + esc(x.name) + '' + esc(x.at > 0 ? (x.rel || clock(x.at)) : '') + '' + (x.note ? '' + esc(x.note) + '' : '') + '
  • '; }).join(''); + $('tl-steps').title = tl.field; + setText('tl-note', 'Times from install (' + (tl.base > 0 ? whenWords(tl.base) : 'not recorded') + '). The site’s /miner page shows the same steps measured on fresh installs.'); } // ---------- render ---------- function badgeHtml(cd) { @@ -1471,11 +1830,13 @@ if (typeof document !== 'undefined') (function () { // ---------- Mine ---------- var mineRowsSig = '', lastEventsKey = ''; - function numCell(cls, v, unit, k, tone) { return '
    ' + esc(v) + (unit ? '' + unit + '' : '') + '' + (k ? '' + esc(k) + '' : '') + '
    '; } + function numCell(cls, v, unit, k, tone, title) { return '
    ' + esc(v) + (unit ? '' + unit + '' : '') + '' + (k ? '' + esc(k) + '' : '') + '
    '; } function gpuLineHtml(cd, s, r) { var cells = View.cells(cd, priceNow()), tw = View.tuneWords(cd, s, s.now), word = View.rowWord(r, s.mining); var nums = ''; if (r.hash) nums += numCell('hash', r.hash, 'MH/s', '', ''); + var ignd = r.hash ? View.cardIgnDay(cd, chain) : ''; + if (ignd) nums += numCell('ign', ignd, 'IGN', 'a day', '', View.FIELDS.share + ' \u00d7 86,400 \u00d7 ' + View.FIELDS.reward); if (cells.power) nums += numCell('power', cells.power.replace(' W', ''), 'W', cells.eff, ''); if (cells.money) nums += numCell('money', cells.money, '', 'a day', ''); else if (cells.needsPrice) nums += '
    £ a day
    '; @@ -1580,7 +1941,7 @@ if (typeof document !== 'undefined') (function () { var tunables = presentCards.filter(function (c) { return View.tunable(c) && c.enabled; }); setHidden('btn-tune-all', !(tunables.length > 0) || !!(s.settings && s.settings.tuning_off) || !!View.tuningCard(presentCards)); var box = $('d-cards'), busy = box.querySelector('input[type=range]:active') || (box.contains(document.activeElement) && document.activeElement.tagName === 'INPUT'); - var sig = JSON.stringify([rows, shown.map(function (c) { return [View.tuneWords(c, s, s.now), View.cells(c, price), c.identities, c.power_pct, c.pinned, c.power_applied, Math.round(c.temp_mem || 0), Math.round(c.gclk_mhz || 0), Math.round(c.mclk_mhz || 0), (c.tune_curve || []).length, c.tune_goal || '', c.sweep_watts, c.sweep_mhs]; }), goalNow()]); + var sig = JSON.stringify([rows, shown.map(function (c) { return [View.tuneWords(c, s, s.now), View.cells(c, price), c.identities, c.power_pct, c.pinned, c.power_applied, Math.round(c.temp_mem || 0), Math.round(c.gclk_mhz || 0), Math.round(c.mclk_mhz || 0), (c.tune_curve || []).length, c.tune_goal || '', c.sweep_watts, c.sweep_mhs, View.cardIgnDay(c, chain)]; }), goalNow()]); if (sig !== mineRowsSig && !busy) { mineRowsSig = sig; box.innerHTML = rows.length ? shown.map(function (cd, i) { return gpuLineHtml(cd, s, rows[i]); }).join('') : '
    ' + (s.detecting ? 'Asking the graphics cards to report in.' : 'No graphics card this app can use was found. The node runs on its own.') + '
    '; @@ -1590,6 +1951,7 @@ if (typeof document !== 'undefined') (function () { $('d-cards-note').hidden = !note; setText('d-cards-note', note); renderTuneCard(s); renderNode(s); + if (page === 'overview') renderFirstMonth(s); // activity: the counts, the strip, the newest 8 events in a user's words var nowU = s.now, f10 = m.found.filter(function (x) { return nowU - x <= 600; }).length; setText('d-stats', f10 + ' block' + (f10 === 1 ? '' : 's') + ' in 10 min · ' + m.found.length + ' in the last hour' + (m.fee_total ? ' · ' + withCommas(m.fee_total) + ' to the dev fee' : '')); @@ -1648,11 +2010,21 @@ if (typeof document !== 'undefined') (function () { function renderEarnings(s) { var a = s.address || {}, m = s.mining, pv = s.proving || {}, price = priceNow(); var ign = pv.paid_wei && Number(pv.paid_wei) ? (Number(pv.paid_wei) / 1e18).toFixed(4) : '0.0000'; - setText('e-ign', ign + ' IGN'); setText('e-ign-sub', 'from ' + (pv.paid || 0) + ' proof' + (pv.paid === 1 ? '' : 's') + (pv.segments_paid ? ' and ' + pv.segments_paid + ' segment' + (pv.segments_paid === 1 ? '' : 's') : '')); - setText('e-blocks', withCommas(m.accepted_total) + ' block' + (m.accepted_total === 1 ? '' : 's')); setText('e-blocks-sub', 'lifetime · ' + withCommas(m.accepted_session) + ' this run, ' + uptime(s.uptime_s)); + setText('e-ign-sub', ign + ' IGN from ' + (pv.paid || 0) + ' proof' + (pv.paid === 1 ? '' : 's') + (pv.segments_paid ? ' and ' + pv.segments_paid + ' segment' + (pv.segments_paid === 1 ? '' : 's') : '')); $('e-ign-sub').title = View.FIELDS.shards; + // miner-ui-5: IGN first (reinvent 3.4); every line names its field on hover + var el = View.earningsLines(s, chain, ignPrice(), price, s.now); + setText('e-day', el.day.text); setText('e-day-sub', el.day.sub); $('e-day').title = el.day.field; + setText('e-window', el.window.text); setText('e-window-sub', el.window.sub); $('e-window').title = el.window.field; + setText('e-price', el.price.text); setText('e-price-sub', el.price.sub); $('e-price').title = el.price.field; + if (document.activeElement !== $('e-price-input')) { var ip = ignPrice(); $('e-price-input').value = ip ? String(ip) : ''; } + setText('e-share', el.share.text); setText('e-share-sub', el.share.sub); $('e-share').title = el.share.field; + setText('e-kwh', el.perKwh ? el.perKwh.text : ''); setHidden('e-kwh', !el.perKwh); $('e-kwh').title = el.perKwh ? el.perKwh.sub + ' \u00b7 ' + el.perKwh.field : ''; + renderLadderCard(s); + setText('e-blocks', withCommas(m.accepted_total) + ' block' + (m.accepted_total === 1 ? '' : 's')); setText('e-blocks-sub', 'lifetime · ' + withCommas(m.accepted_session) + ' this run, ' + uptime(s.uptime_s)); $('e-blocks').title = View.FIELDS.found; var watts = View.fleetWatts(m, m.cards), ppd = View.fleetPounds(m, m.cards, price); var bw = View.balanceWords(a, s.now); - if (bw.text) { setText('e-earned', bw.pounds !== null ? money(bw.pounds) : bw.text); setText('e-earned-sub', bw.pounds !== null ? 'at the signed price · ' + bw.text + ' ' + bw.sub : (bw.text === 'not read yet' ? 'balance ' + bw.sub : bw.sub + '. Nothing is bought or sold on devnet')); } + if (bw.text) { setText('e-earned', bw.pounds !== null ? money(bw.pounds) : bw.text); setText('e-earned-sub', bw.pounds !== null ? 'at the signed price · ' + bw.text + ' ' + bw.sub : (bw.text === 'not read yet' ? 'balance ' + bw.sub : bw.sub + '. Nothing is bought or sold on devnet')); $('e-earned').title = 'address.balance_wei: the node\u2019s eth_getBalance of the rewards address'; } + else { setText('e-earned', 'balance: not on this engine'); setText('e-earned-sub', 'the wallet shows it'); } setText('e-cost', ppd === null ? (watts > 0 ? Math.round(watts) + ' W' : 'no draw') : money(ppd) + ' a day'); setText('e-cost-sub', ppd === null ? (watts > 0 ? 'electricity: set a price to see the cost' : 'electricity: no card reports its draw') : 'electricity at ' + price + 'p/kWh for ' + Math.round(watts) + ' W'); setHidden('e-price-btn', !(watts > 0 && ppd === null)); @@ -1672,6 +2044,10 @@ if (typeof document !== 'undefined') (function () { var tiersSig = ''; function renderProve(s) { var pv = s.proving || {}, enabled = !!(s.settings && s.settings.prove); + // miner-ui-5: "your card proved shard N of block M" from the newest paid record (ladder.shards) + var sh = ((s.ladder || {}).shards || [])[0]; + setHidden('shard-card', !sh); + if (sh) { setText('sc-when', clock(sh.at)); setText('sc-title', 'Your card proved shard ' + sh.shard + ' of block ' + withCommas(sh.block) + '.'); setText('sc-line', View.fmtIgn(View.ign(sh.wei)) + ' paid for it \u00b7 ' + withCommas(pv.paid || 0) + ' shard' + (pv.paid === 1 ? '' : 's') + ' paid on this machine'); setText('sc-sub', '20 points of every block pay the provers, split by shard. A paid record is a chain fact: anyone can read it.'); $('sc-title').title = View.FIELDS.shards; $('sc-line').title = 'proving.paid_wei, proving.paid'; } if (document.activeElement !== $('s-prove')) $('s-prove').checked = enabled; var w = View.proveWords(pv, enabled, s.node.synced); var cards = shownCards(s.mining.cards).filter(View.present), tiers = cards.map(View.proveTier); @@ -1694,7 +2070,8 @@ if (typeof document !== 'undefined') (function () { var jobsSig = ''; function renderSettings(s) { var st = s.settings || {}, sw = function (id, on) { if (document.activeElement !== $(id)) $(id).checked = !!on; }; - sw('s-vote', st.vote); sw('s-login', st.start_at_login); sw('s-jobs-allow', s.jobs && s.jobs.allowed); sw('s-sweep', st.sweep); sw('s-trust', st.proof_verify_trust); sw('s-power-control', st.power_control); sw('s-climb', st.tune_climb); + sw('s-vote', st.vote); sw('s-login', st.start_at_login); sw('s-jobs-allow', s.jobs && s.jobs.allowed); sw('s-sweep', st.sweep); sw('s-trust', st.proof_verify_trust); sw('s-power-control', st.power_control); sw('s-climb', st.tune_climb); sw('s-profile', st.profile_public); + setText('s-profile-text', View.profileWords(!!st.profile_public)); setHidden('s-climb-row', st.tune_climb === undefined); setText('s-power-note', st.tuning_off ? ' ' + (st.tuning_note || '') : (st.power_note ? ' ' + st.power_note : '')); if (document.activeElement !== $('s-price')) { var p = priceNow(); $('s-price').value = p > 0 ? String(p) : ''; } @@ -1939,6 +2316,7 @@ if (typeof document !== 'undefined') (function () { if (params.get('logs') === '1' && start === 'dashboard') setDrawer(true); // screenshots: the log drawer open } render(s); + pollChain(false); perf.render += performance.now() - a; perf.polls++; if (failures === 0 && $('pill').classList.contains('away')) { $('pill').classList.remove('away', 'warn'); } }).catch(function () { diff --git a/app/igneum-app/ui/index.html b/app/igneum-app/ui/index.html index 5bd14791a..d44a3b590 100644 --- a/app/igneum-app/ui/index.html +++ b/app/igneum-app/ui/index.html @@ -185,15 +185,73 @@ + + + + +
    -

    The chain, live

    connecting
    +

    The chain, live

    connecting
    +
    - +
    -
    yourspendingincludedexcludedprovenlocked checkpoint
    +
    Your blockspendingIncludedExcludedProvenLocked checkpoint
    + +
    @@ -265,16 +323,35 @@