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 5e82582ea..d87e2a2f4 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 56784cdbb..ed888197a 100644 --- a/app/igneum-app/src/prover.rs +++ b/app/igneum-app/src/prover.rs @@ -525,6 +525,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. 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 b2603b4d5..a4bf6ded2 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: 60, 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..43c7283f1 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 @@