diff --git a/app/igneum-app/src/card.rs b/app/igneum-app/src/card.rs new file mode 100644 index 000000000..d1447675c --- /dev/null +++ b/app/igneum-app/src/card.rs @@ -0,0 +1,80 @@ +//! The saved block card (miner-ui-5): the page draws the 1200x630 PNG on a canvas from the same words the card +//! shows (no network call), posts it as a data URL to POST /api/card, and the engine writes it under +//! `/cards/`. This module is the pure part: the base64 decode (no crate for it) and the file name. +use std::path::{Path, PathBuf}; + +/// `data:image/png;base64,....` -> the PNG bytes; None for anything that is not a base64 PNG data URL. +pub fn decode_data_url(s: &str) -> Option> { + let rest = s.strip_prefix("data:image/png;base64,")?; + let bytes = decode_base64(rest)?; + if bytes.len() < 8 || bytes[..8] != [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] { + return None; + } + Some(bytes) +} + +/// Standard base64 (RFC 4648, with or without `=` padding, whitespace ignored). None on a bad character. +pub fn decode_base64(s: &str) -> Option> { + let mut out = Vec::with_capacity(s.len() * 3 / 4); + let mut acc: u32 = 0; + let mut bits = 0u32; + for c in s.bytes() { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' | b'-' => 62, + b'/' | b'_' => 63, + b'=' | b'\n' | b'\r' | b' ' | b'\t' => continue, + _ => return None, + } as u32; + acc = (acc << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push(((acc >> bits) & 0xFF) as u8); + } + } + Some(out) +} + +/// `/cards/igneum-block--.png`, the name reduced to [a-z0-9-] and at most 40 characters. +pub fn card_path(root: &Path, name: &str, unix: u64) -> PathBuf { + let mut clean = String::new(); + for c in name.to_ascii_lowercase().chars() { + if c.is_ascii_alphanumeric() { clean.push(c); } else if !clean.ends_with('-') { clean.push('-'); } + } + let clean: String = clean.trim_matches('-').chars().take(40).collect::().trim_end_matches('-').to_string(); + let clean = if clean.is_empty() { "block".to_string() } else { clean }; + root.join("cards").join(format!("igneum-block-{clean}-{unix}.png")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64_decodes_with_and_without_padding() { + assert_eq!(decode_base64("aGVsbG8=").unwrap(), b"hello"); + assert_eq!(decode_base64("aGVsbG8").unwrap(), b"hello"); + assert_eq!(decode_base64("aGVs\nbG8gd29ybGQ=").unwrap(), b"hello world"); + assert!(decode_base64("aGV$sbG8=").is_none()); + } + + #[test] + fn a_png_data_url_decodes_and_anything_else_is_refused() { + // the 8-byte PNG signature, base64 + let sig = "iVBORw0KGgo="; + let png = decode_data_url(&format!("data:image/png;base64,{sig}")).unwrap(); + assert_eq!(&png[..4], b"\x89PNG"); + assert!(decode_data_url("data:image/jpeg;base64,/9j/").is_none()); + assert!(decode_data_url("data:image/png;base64,aGVsbG8=").is_none(), "not a PNG"); + } + + #[test] + fn the_file_name_is_safe_and_dated() { + let p = card_path(Path::new("/data"), "Block 1,284,117 / RTX 4070", 1_791_362_116); + assert_eq!(p, PathBuf::from("/data/cards/igneum-block-block-1-284-117-rtx-4070-1791362116.png")); + assert_eq!(card_path(Path::new("/d"), "///", 1).file_name().unwrap(), "igneum-block-block-1.png"); + } +} diff --git a/app/igneum-app/src/chainfacts.rs b/app/igneum-app/src/chainfacts.rs new file mode 100644 index 000000000..3cbc0e1d8 --- /dev/null +++ b/app/igneum-app/src/chainfacts.rs @@ -0,0 +1,263 @@ +//! GET /api/ladder: the chain facts behind the miner's ladder (miner-ui-5, 7 October 2026). Every rung the dashboard +//! shows is a number from the node's finality RPC, never a count this app keeps: `getFinalityWeights` (the per-key +//! table: `keys[].blocks` in the weight window, `keys[].voter` against `params.dust`, `keys[].participation` over +//! `params.presenceWindow`, `totalWeight`, `activeWeight`, `voters`, `checkpointIndex`) and `getFinalityCheckpoints` +//! (`latestLockedIndex`, `nextIndex`, `finalityActive`). The network rate and the reward come from the public +//! `/api/stats` (`hashrate`, `block_reward.miner_ign`, `block_reward.ramp_factor`), the observer's reading of the same +//! node RPCs. +//! +//! Sources, in order: the local node's EVM port answering `igneum_getFinalityWeights` (OWED on the node: the finality +//! RPCs are wRPC only today and the engine carries no websocket client), else the observer's `/api/live`, whose +//! `finality.weights` is the node's `getFinalityWeights` relayed (field names in snake case, `keys[]` cut to 64, key ids +//! as the first 8 hex of the key hash). The reply names its source so the dashboard can say it on hover. Cached 10 s. +use serde_json::{json, Value}; +use std::process::Command; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +static CACHE: Mutex> = Mutex::new(None); +const TTL: Duration = Duration::from_secs(10); +/// the observer relays at most this many keys (tools/observer/observer.mjs) +pub const OBSERVER_KEYS_CAP: usize = 64; + +fn num(v: &Value) -> f64 { + match v { + Value::Number(n) => n.as_f64().unwrap_or(0.0), + Value::String(s) => s.parse().unwrap_or(0.0), + Value::Bool(b) => if *b { 1.0 } else { 0.0 }, + _ => 0.0, + } +} +fn u(v: &Value) -> u64 { num(v).max(0.0) as u64 } +fn get<'a>(v: &'a Value, keys: &[&str]) -> &'a Value { + for k in keys { + if let Some(x) = v.get(k) { return x; } + } + &Value::Null +} + +/// One key's row in either spelling (the node's camelCase or the observer's snake case). +fn key_row(k: &Value) -> Value { + let id_full = get(k, &["keyHash", "key_hash", "id"]).as_str().unwrap_or("").trim_start_matches("0x").to_ascii_lowercase(); + let id: String = id_full.chars().take(8).collect(); + let revealed = match k.get("revealed") { Some(r) => r.as_bool().unwrap_or(false), None => k.get("pubkey").and_then(|p| p.as_str()).map(|p| !p.is_empty()).unwrap_or(false) }; + json!({ + "id": id, + "blocks": u(get(k, &["blocks"])), + "voter": get(k, &["voter"]).as_bool().unwrap_or(false), + "participation": num(get(k, &["participation"])), + "stripped_until_daa": u(get(k, &["strippedUntilDaa", "stripped_until_daa"])), + "revealed": revealed, + }) +} + +/// Is `id` one of this machine's key ids? A prefix of the other of at least 6 hex counts (the engine keeps 8, a +/// source may keep more or fewer). +pub fn is_mine(id: &str, ids: &[String]) -> bool { + let id = id.trim_start_matches("0x").to_ascii_lowercase(); + ids.iter().any(|m| { + let m = m.trim_start_matches("0x").to_ascii_lowercase(); + let n = id.len().min(m.len()); + n >= 6 && id[..n] == m[..n] + }) +} + +/// The reply from a weights table (`w`: the node's `getFinalityWeights` reply, or the observer's +/// `finality.weights` with `finality` beside it), the observer's `finality` block (params and the locked index), the +/// public stats, and this machine's key ids. Pure. +pub fn shape(w: &Value, finality: &Value, stats: &Value, ids: &[String], source: &str, read_at: f64) -> Value { + let params = if w.get("params").is_some() { get(w, &["params"]) } else { get(finality, &["params"]) }; + let mut keys: Vec = get(w, &["keys"]).as_array().map(|a| a.iter().map(key_row).collect()).unwrap_or_default(); + keys.sort_by(|a, b| u(&b["blocks"]).cmp(&u(&a["blocks"])).then_with(|| a["id"].as_str().cmp(&b["id"].as_str()))); + let mut mine: Vec = Vec::new(); + for (i, k) in keys.iter().enumerate() { + if is_mine(k["id"].as_str().unwrap_or(""), ids) { + let mut m = k.clone(); + m["rank"] = json!(i + 1); + mine.push(m); + } + } + let best = mine.iter().min_by_key(|m| u(&m["rank"])).cloned(); + let reward = get(stats, &["block_reward"]); + let latest_locked = if finality.get("latest_locked_index").is_some() { u(get(finality, &["latest_locked_index"])) } else { u(get(get(stats, &["finality"]), &["latest_locked_index"])) }; + let active = match finality.get("active").or_else(|| finality.get("finality_active")) { Some(a) => a.as_bool().unwrap_or(false), None => get(get(stats, &["finality"]), &["active"]).as_bool().unwrap_or(false) }; + json!({ + "ok": true, + "source": source, + "read_at": read_at, + "rpc": { + "weights": "getFinalityWeights", + "checkpoints": "getFinalityCheckpoints", + "stats": "/api/stats", + "relay": if source == "node" { "" } else { "igneum.network/api/live (the observer's copy of the node's reply)" } + }, + "params": { + "dust": u(get(params, &["dust"])), + "weight_window": u(get(params, &["weightWindow", "weight_window"])), + "presence_window": u(get(params, &["presenceWindow", "presence_window"])), + "checkpoint_interval": u(get(params, &["checkpointInterval", "checkpoint_interval"])), + "min_daa": u(get(params, &["minDaa", "min_daa"])), + }, + "checkpoint_index": u(get(w, &["checkpointIndex", "checkpoint_index"])), + "daa_score": u(get(w, &["daaScore", "daa_score"])), + "latest_locked_index": latest_locked, + "next_index": u(get(finality, &["nextIndex", "next_index"])), + "finality_active": active, + "total_weight": u(get(w, &["totalWeight", "total_weight"])), + "active_weight": num(get(w, &["activeWeight", "active_weight"])), + "voters": u(get(w, &["voters"])), + "keys_listed": keys.len(), + "keys_cap": if source == "node" { 0 } else { OBSERVER_KEYS_CAP }, + "network": { + "hashrate_hps": num(get(stats, &["hashrate"])), + "miner_ign_per_block": num(get(reward, &["miner_ign"])), + "ign_per_block": num(get(reward, &["ign"])), + "ramp_factor": num(get(reward, &["ramp_factor"])), + "daa": u(get(stats, &["daa"])), + "blocks_per_day_measured": u(get(stats, &["blocks_per_day_measured"])), + "bps": 1, + "stale": get(stats, &["stale"]).as_bool().unwrap_or(stats.is_null()), + }, + "mine": mine, + "best": best, + }) +} + +fn curl_json(url: &str) -> Option { + let out = crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "6", url]), None, Duration::from_secs(8))?; + serde_json::from_str(out.trim()).ok() +} + +/// `https://igneum.network/api/live` -> `https://igneum.network/api/stats` +pub fn stats_api_from(live_api: &str) -> String { + match live_api.strip_suffix("/api/live") { + Some(base) => format!("{base}/api/stats"), + None => String::new(), + } +} + +/// The reply for the dashboard, cached 10 s: the node first, the observer second, `{ok:false}` with the reason when +/// neither answers. +pub fn fetch(evm_port: u16, live_api: &str, ids: &[String]) -> Value { + if let Some((at, v)) = CACHE.lock().unwrap().as_ref() { + if at.elapsed() < TTL { + let mut c = v.clone(); + c["cached_age_s"] = json!(at.elapsed().as_secs_f64().round()); + return c; + } + } + let now = crate::platform::unix_now_f(); + let stats = if live_api.is_empty() { None } else { curl_json(&stats_api_from(live_api)) }; + let stats = stats.unwrap_or(Value::Null); + let reply = match crate::extnode::rpc(evm_port, "igneum_getFinalityWeights", json!([]), Duration::from_secs(4)) { + Some(w) if w.get("keys").is_some() => shape(&w, &Value::Null, &stats, ids, "node", now), + _ => match if live_api.is_empty() { None } else { curl_json(&crate::live::url_for(live_api, 60)) } { + Some(live) => { + let fin = get(&live, &["finality"]); + let w = get(fin, &["weights"]); + if w.get("keys").is_some() { shape(w, fin, &stats, ids, "observer", now) } else { json!({ "ok": false, "error": "the observer carries no finality weights yet", "source": "observer" }) } + } + None => json!({ "ok": false, "error": if live_api.is_empty() { "no observer address in this build and the node has no igneum_getFinalityWeights" } else { "neither the node nor the observer answered" }, "source": "" }), + }, + }; + *CACHE.lock().unwrap() = Some((Instant::now(), reply.clone())); + reply +} + +#[cfg(test)] +mod tests { + use super::*; + + fn observer_live() -> Value { + json!({ + "ok": true, + "state": { "hashes_per_second_estimate": 764944722 }, + "finality": { + "supported": true, "active": true, "next_index": 8496, "latest_locked_index": 8495, + "params": { "dust": 5, "minDaa": 7200, "aggregators": 8, "weightWindow": 7200, "presenceWindow": 20, "checkpointDepth": 20, "equivocationBan": 7200, "checkpointInterval": 30 }, + "weights": { + "checkpoint_index": 8495, "total_weight": 6000, "active_weight": 5900.5, "voters": 14, + "keys": [ + { "id": "6e80f3ef", "voter": true, "blocks": 680, "revealed": true, "participation": 1, "stripped_until_daa": 0 }, + { "id": "9e4ba6b0", "voter": true, "blocks": 559, "revealed": true, "participation": 1, "stripped_until_daa": 0 }, + { "id": "8fafda27", "voter": true, "blocks": 61, "revealed": true, "participation": 0.95, "stripped_until_daa": 0 }, + { "id": "00000001", "voter": false, "blocks": 3, "revealed": false, "participation": 0, "stripped_until_daa": 0 } + ] + } + } + }) + } + fn stats() -> Value { + json!({ "ok": true, "stale": false, "daa": 266454, "hashrate": 764944722, "blocks_per_day_measured": 92280, "block_reward": { "miner_ign": "4.88044084", "ign": "6.10055105", "ramp_factor": 0.192519 }, "finality": { "active": true, "latest_locked_index": 8495 } }) + } + + #[test] + fn the_observer_relay_shapes_into_the_node_fields_with_our_rank() { + let live = observer_live(); + let fin = &live["finality"]; + let r = shape(&fin["weights"], fin, &stats(), &["8fafda27".into()], "observer", 1.0); + assert_eq!(r["ok"], true); + assert_eq!(r["source"], "observer"); + assert_eq!(r["params"]["dust"], 5); + assert_eq!(r["params"]["weight_window"], 7200); + assert_eq!(r["params"]["presence_window"], 20); + assert_eq!(r["latest_locked_index"], 8495); + assert_eq!(r["finality_active"], true); + assert_eq!(r["voters"], 14); + assert_eq!(r["keys_listed"], 4); + assert_eq!(r["keys_cap"], 64); + assert_eq!(r["network"]["hashrate_hps"], 764944722.0); + assert_eq!(r["network"]["miner_ign_per_block"], 4.88044084); + assert_eq!(r["mine"].as_array().unwrap().len(), 1); + assert_eq!(r["mine"][0]["rank"], 3); + assert_eq!(r["mine"][0]["blocks"], 61); + assert_eq!(r["mine"][0]["voter"], true); + assert_eq!(r["best"]["id"], "8fafda27"); + assert!(r["rpc"]["relay"].as_str().unwrap().contains("observer")); + } + + #[test] + fn the_node_reply_in_camel_case_shapes_the_same_and_names_no_relay() { + let w = json!({ + "params": { "checkpointInterval": 30, "checkpointDepth": 20, "weightWindow": 2592000, "dust": 100, "presenceWindow": 240, "aggregators": 8, "equivocationBan": 2592000, "minDaa": 7200 }, + "checkpointIndex": 184220, "checkpointHash": "ab", "daaScore": 1284117, "totalWeight": 2000000, "activeWeight": 1900000.0, "voters": 1204, + "keys": [ + { "keyHash": "8fafda27aa11bb22cc33dd44", "pubkey": "a1", "blocks": 2592, "voter": true, "participation": 1.0, "strippedUntilDaa": 0 }, + { "keyHash": "0000000100000000", "pubkey": "", "blocks": 44, "voter": false, "participation": 0.0, "strippedUntilDaa": 0 } + ] + }); + let r = shape(&w, &Value::Null, &stats(), &["8fafda27".into()], "node", 2.0); + assert_eq!(r["source"], "node"); + assert_eq!(r["keys_cap"], 0); + assert_eq!(r["params"]["dust"], 100); + assert_eq!(r["params"]["weight_window"], 2592000); + assert_eq!(r["checkpoint_index"], 184220); + assert_eq!(r["mine"][0]["id"], "8fafda27"); + assert_eq!(r["mine"][0]["rank"], 1); + assert_eq!(r["mine"][0]["revealed"], true); + assert_eq!(r["rpc"]["relay"], ""); + // the locked index falls back to the public stats when the node reply carries no checkpoints block + assert_eq!(r["latest_locked_index"], 8495); + } + + #[test] + fn a_machine_with_no_key_in_the_table_has_no_rank() { + let live = observer_live(); + let fin = &live["finality"]; + let r = shape(&fin["weights"], fin, &Value::Null, &["deadbeef".into()], "observer", 1.0); + assert_eq!(r["mine"].as_array().unwrap().len(), 0); + assert!(r["best"].is_null()); + assert_eq!(r["network"]["stale"], true); + assert_eq!(r["network"]["hashrate_hps"], 0.0); + } + + #[test] + fn id_matching_takes_a_prefix_of_six_or_more() { + assert!(is_mine("8fafda27", &["8fafda27aa11".into()])); + assert!(is_mine("0x8fafda27aa", &["8fafda27".into()])); + assert!(!is_mine("8fafd", &["8fafda27".into()])); + assert!(!is_mine("8fafda28", &["8fafda27".into()])); + assert_eq!(stats_api_from("https://igneum.network/api/live"), "https://igneum.network/api/stats"); + assert_eq!(stats_api_from(""), ""); + } +} diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs index 51beeaf75..770a3c7a5 100644 --- a/app/igneum-app/src/config.rs +++ b/app/igneum-app/src/config.rs @@ -129,6 +129,10 @@ pub struct Settings { /// on when the machine can prove, never switching an explicit on back off). Older installs apply it at their next start. #[serde(default)] pub prove_default_applied: bool, + /// miner-ui-5 (7 October 2026): the public address profile on the site is behind this opt-in; off by default, and + /// the switch's words say what becomes public (the key ids, the blocks, the weight rank, the card model). + #[serde(default)] + pub profile_public: bool, } fn one() -> u32 { @@ -143,7 +147,7 @@ fn yes() -> bool { impl Default for Settings { fn default() -> Settings { - Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false } + Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false } } } diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 202c62b63..bdbcd7b25 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -16,6 +16,9 @@ use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +/// miner-ui-5: a signing streak breaks when no VOTE line comes for this long (the devnet's presence window, 20 +/// checkpoints of 30 s; the chain-side fact is `getFinalityWeights.keys[].participation`, this is the local reading) +pub const LADDER_GAP_S: f64 = 600.0; const POW_EPOCH_BLOCKS: u64 = 3_600; #[derive(Clone, Debug, PartialEq)] @@ -110,12 +113,17 @@ pub struct Shared { port: std::sync::atomic::AtomicU16, /// set once `state_json` has logged a serialisation error (one line, not one per poll) state_error_logged: std::sync::atomic::AtomicBool, + /// miner-ui-5: the machine's own ladder record (src/ladder.rs), `/ladder.json` + pub ladder: Mutex, + pub ladder_path: PathBuf, } impl Shared { pub fn new(token: String, runtime: Runtime, packaged: Packaged, settings: Settings, cmd_tx: Sender, engine_log: Option, log_path: PathBuf) -> Shared { let settings_path = runtime.app_dir.join("settings.json"); let wallet_path = runtime.app_dir.join("wallet.json"); + let ladder_path = runtime.app_dir.join("ladder.json"); + let ladder = crate::ladder::Ladder::load(&ladder_path); let mut st = State { version: VERSION.into(), ..Default::default() }; st.network = runtime.network.clone(); st.chain = if runtime.network == "devnet" { "devnet v4".into() } else { format!("{} (private)", runtime.network) }; @@ -133,7 +141,7 @@ impl Shared { st.mining.accepted_total = settings.accepted_total; st.mining.fee_total = settings.fee_total; st.address = address_state(&settings, &wallet_path); - st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust }; + st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust, profile_public: settings.profile_public }; st.dev_fee = crate::state::DevFeeState { on: settings.dev_fee, percent: if settings.dev_fee { 1 } else { 0 }, address: String::new(), line: String::new() }; st.live_page = packaged.live_page.clone(); st.finality.message = "waiting for the miner".into(); @@ -155,9 +163,21 @@ impl Shared { log_path, port: std::sync::atomic::AtomicU16::new(0), state_error_logged: std::sync::atomic::AtomicBool::new(false), + ladder: Mutex::new(ladder), + ladder_path, } } + /// miner-ui-5: the ladder record to disk (a few KB; after a block, a lock or a paid shard) + pub fn save_ladder(&self) { + self.ladder.lock().unwrap().save(&self.ladder_path); + } + /// miner-ui-5: a paid shard record of ours (src/prover.rs) joins the ladder's shard list + pub fn ladder_shard_paid(&self, block: u64, shard: u64, wei: u128) { + self.ladder.lock().unwrap().on_shard_paid(crate::platform::unix_now_f(), block, shard, wei); + self.save_ladder(); + } + /// The first line of every upload, so the console's Machines tab parses it without guessing: /// IGNEUM-APP version= machine= platform= node=. Logged at every engine start too, /// so the restart after an OTA apply carries it again. @@ -197,6 +217,7 @@ impl Shared { st.now = crate::platform::unix_now_f(); st.uptime_s = self.started.elapsed().as_secs(); st.events = self.rings.lock().unwrap().events.iter().cloned().collect(); + st.ladder = self.ladder.lock().unwrap().state(st.now, LADDER_GAP_S); match serde_json::to_value(st) { Ok(v) => v, Err(e) => { @@ -333,7 +354,7 @@ impl Shared { Ok(json!({ "ok": true })) } - pub fn apply_settings(&self, identities: Option, vote: Option, login: Option, address: Option<&str>, display_name: Option<&str>, dev_fee: Option, proof_verify_trust: Option) -> Result { + pub fn apply_settings(&self, identities: Option, vote: Option, login: Option, address: Option<&str>, display_name: Option<&str>, dev_fee: Option, proof_verify_trust: Option, profile_public: Option) -> Result { let mut restart = Vec::new(); let mut restart_node: Option = None; { @@ -367,6 +388,12 @@ impl Shared { restart_node = Some(if v { "proof trust mode on (devnet only)".into() } else { "proof trust mode off".into() }); } } + if let Some(v) = profile_public { + if v != s.profile_public { + s.profile_public = v; + self.event("info", if v { "public profile on: your key ids, blocks, weight rank and card model may be shown on igneum.network" } else { "public profile off: the address page stays unlisted" }); + } + } if let Some(a) = address { let a = a.trim().to_ascii_lowercase(); if !a.is_empty() && a != s.address { @@ -386,6 +413,7 @@ impl Shared { st.settings.vote = s.vote; st.settings.dev_fee = s.dev_fee; st.settings.proof_verify_trust = s.proof_verify_trust; + st.settings.profile_public = s.profile_public; st.dev_fee.on = s.dev_fee; st.dev_fee.percent = if s.dev_fee { 1 } else { 0 }; st.address = address_state(&s, &self.wallet_path); @@ -3813,6 +3841,11 @@ impl Engine { /// igneumd's own lines. One matters for the user: relayed blocks refused as "too far into the future", which is /// this machine's clock running behind the network (PC 2, 4 October 2026: 60 s slow after a power cut, 0 blocks). fn node_line(&mut self, text: &str) { + // miner-ui-5: every block the node validates, ours and relayed, by nonce; the miner's ACCEPTED line claims ours + if let Some((hash, daa, nonce)) = crate::ladder::parse_node_accepted(text) { + self.shared.ladder.lock().unwrap().on_node_accepted(&hash, daa, &nonce); + return; + } if let Some(d) = digest_from_line(text) { let mut st = self.st(); if st.node.consensus_digest != d { @@ -4002,6 +4035,8 @@ impl Engine { } if synced && !was_synced { self.shared.event("ok", &format!("node synced: {blocks} blocks, {peers} peer(s)")); + // miner-ui-5: the first-hour timeline's "node synced" mark, once per install + if self.shared.ladder.lock().unwrap().mark("synced", crate::platform::unix_now_f()) { self.shared.save_ladder(); } let t = self.secs(now); let names: Vec = self.st().mining.cards.iter().map(|c| c.name.clone()).collect(); for m in self.miners.iter_mut() { @@ -4127,8 +4162,16 @@ impl Engine { if let Some(c) = st.mining.cards.get_mut(card) { c.accepted += 1; } + let card_key = st.mining.cards.get(card).map(|c| c.key.clone()).unwrap_or_default(); drop(st); self.shared.event("block", &format!("block accepted by the node ({card_name})")); + // miner-ui-5: the machine's own ladder record (the block card, the days ring); the hash joins by nonce + let nonce = crate::ladder::parse_miner_accepted(text).unwrap_or_default(); + let milestone = self.shared.ladder.lock().unwrap().on_block(unix, &card_key, &card_name, &nonce, total); + self.shared.save_ladder(); + if let Some(m) = milestone { + self.shared.event("block", &crate::ladder::milestone_event(&m)); + } } else if let Some(s) = crate::watchdog::parse_status(text) { let now = Instant::now(); self.miners[i].last_status = Some(now); @@ -4167,6 +4210,8 @@ impl Engine { c.message = if s.mismatched > 0 { format!("{} share(s) failed the CPU re-check this run", s.mismatched) } else { String::new() }; } } + // miner-ui-5: the first-hour timeline's "card mining" mark, once per install, on the first status with a rate + if s.hash_now > 0.0 && self.shared.ladder.lock().unwrap().mark("mining", crate::platform::unix_now_f()) { self.shared.save_ladder(); } } else if text.contains(" worker: race ") { self.race_line(card, &card_name, text); } else if text.contains(" worker: ready ") { @@ -4228,9 +4273,16 @@ impl Engine { st.finality.last_lock_at = crate::platform::unix_now_f(); st.finality.age_s = 0.0; } + drop(st); + // miner-ui-5: every checkpoint this machine signed at or under the lock is in a certificate now + self.shared.ladder.lock().unwrap().on_lock(n); + self.shared.save_ladder(); } } else if text.contains(" VOTE index=") { self.st().finality.votes += 1; + if let Some(idx) = crate::ladder::parse_vote_index(text) { + self.shared.ladder.lock().unwrap().on_vote(crate::platform::unix_now_f(), idx, LADDER_GAP_S); + } } else if text.contains("worker could not prepare") || text.contains(" prepare-failed ") { self.st().program.prepared = false; self.shared.event("error", &format!("{card_name}: the worker could not compile the next program; it compiles at the boundary")); diff --git a/app/igneum-app/src/ladder.rs b/app/igneum-app/src/ladder.rs new file mode 100644 index 000000000..99486799c --- /dev/null +++ b/app/igneum-app/src/ladder.rs @@ -0,0 +1,514 @@ +//! The miner's first month, as this machine records it (miner-ui-5, mission item 6, 7 October 2026). The ladder's +//! rungs are chain facts (src/chainfacts.rs reads them from `getFinalityWeights` and `getFinalityCheckpoints`); this +//! module keeps what only this machine knows: when its first block came and on which card, the blocks it found per day +//! (the days-mined ring), every checkpoint its miners signed and which of those locked (the signing streak), the shards +//! its cards were paid for, and the block card to show (the first block, every 100th, 1,000th and 10,000th, the first +//! on a new card). Persisted as `/ladder.json`; pure, no clock of its own (every call takes `now`). +//! +//! A block is matched to its hash by the nonce: the miner prints `ACCEPTED block nonce=0x..` for ours, the node prints +//! `PoW accepted by .. (daa N, .., nonce 0x..)` for every block it validates, ours and relayed alike, so the +//! nonce is the join and nothing else is. +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +pub const DAY_S: f64 = 86_400.0; +/// days kept in the ring (the 30-day window plus a month of slack for the view) +const KEEP_DAYS: u64 = 60; +const KEEP_BLOCKS: usize = 30; +const KEEP_SHARDS: usize = 50; +const KEEP_PENDING: usize = 400; +const KEEP_SIGNED: usize = 400; +/// the block counts that raise a card (then every 10,000th) +pub const MILESTONES: [u64; 4] = [1, 100, 1_000, 10_000]; + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct DayBlocks { + /// unix day number (unix seconds / 86,400, UTC) + pub day: u64, + pub blocks: u64, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct BlockFound { + pub hash: String, + pub daa: u64, + pub nonce: String, + pub at: f64, + pub card: String, + pub card_name: String, + /// this machine's lifetime count at that block (1 = the first) + pub count: u64, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct ShardPaid { + pub block: u64, + pub shard: u64, + /// wei as a decimal string (u128 does not survive every JSON reader) + pub wei: String, + pub at: f64, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct Milestone { + /// first | hundred | thousand | ten_thousand | card + pub kind: String, + pub count: u64, + pub card: String, + pub card_name: String, + pub hash: String, + pub daa: u64, + pub at: f64, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct Ladder { + #[serde(default)] + pub first_block_at: f64, + #[serde(default)] + pub first_block_card: String, + #[serde(default)] + pub first_block_hash: String, + #[serde(default)] + pub first_block_daa: u64, + #[serde(default)] + pub days: Vec, + /// card key -> unix s of its first block + #[serde(default)] + pub cards_first: BTreeMap, + #[serde(default)] + pub blocks: Vec, + #[serde(default)] + pub votes_signed: u64, + #[serde(default)] + pub last_vote_index: u64, + #[serde(default)] + pub last_vote_at: f64, + /// checkpoints signed and not yet seen locked + #[serde(default)] + pub signed_open: Vec, + #[serde(default)] + pub last_signed_locked: u64, + #[serde(default)] + pub signed_locked_count: u64, + /// when the unbroken run of votes began (0 = no vote yet) + #[serde(default)] + pub streak_since: f64, + #[serde(default)] + pub shards: Vec, + #[serde(default)] + pub milestone: Option, + /// the first-hour timeline's marks: when this install's node first synced and a card first mined (0 = not yet) + #[serde(default)] + pub first_synced_at: f64, + #[serde(default)] + pub first_mining_at: f64, + /// node-accepted blocks waiting for the miner's own ACCEPTED line (nonce -> hash, daa), and the reverse case + #[serde(default)] + pending: Vec<(String, String, u64)>, +} + +/// What the dashboard reads (api/state.ladder): the record above summarised at `now`. +#[derive(Clone, Debug, Default, Serialize)] +pub struct LadderState { + pub first_block_at: f64, + pub first_block_card: String, + pub first_block_hash: String, + pub first_block_daa: u64, + /// distinct UTC days with at least one block in the last 30 days + pub days_mined_30: u64, + pub blocks_30d: u64, + pub blocks_today: u64, + pub days: Vec, + pub cards_first: BTreeMap, + pub blocks: Vec, + pub votes_signed: u64, + pub last_vote_index: u64, + pub last_vote_at: f64, + pub last_signed_locked: u64, + pub signed_locked_count: u64, + /// seconds of unbroken signing (0 = none) + pub streak_s: f64, + pub shards: Vec, + pub milestone: Option, + pub first_synced_at: f64, + pub first_mining_at: f64, +} + +pub fn day_of(unix: f64) -> u64 { + (unix.max(0.0) / DAY_S).floor() as u64 +} + +impl Ladder { + pub fn load(path: &std::path::Path) -> Ladder { + std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default() + } + pub fn save(&self, path: &std::path::Path) { + if let Some(d) = path.parent() { + let _ = std::fs::create_dir_all(d); + } + if let Ok(t) = serde_json::to_string_pretty(self) { + let _ = std::fs::write(path, t); + } + } + + /// The node validated a block (its own log line): remembered by nonce until the miner's line claims it. + pub fn on_node_accepted(&mut self, hash: &str, daa: u64, nonce: &str) { + let nonce = norm_nonce(nonce); + if nonce.is_empty() || hash.is_empty() { + return; + } + // a block of ours whose miner line came first: fill the hash in now + if let Some(b) = self.blocks.iter_mut().find(|b| b.hash.is_empty() && b.nonce == nonce) { + b.hash = hash.to_string(); + b.daa = daa; + if self.first_block_hash.is_empty() && self.first_block_at == b.at { + self.first_block_hash = hash.to_string(); + self.first_block_daa = daa; + } + if let Some(m) = self.milestone.as_mut() { + if m.hash.is_empty() && m.at == b.at { + m.hash = hash.to_string(); + m.daa = daa; + } + } + return; + } + self.pending.push((nonce, hash.to_string(), daa)); + if self.pending.len() > KEEP_PENDING { + let n = self.pending.len() - KEEP_PENDING; + self.pending.drain(0..n); + } + } + + /// The miner's own ACCEPTED line: `total` is this machine's lifetime count including this block. Returns the + /// milestone this block raised, if any. + pub fn on_block(&mut self, now: f64, card: &str, card_name: &str, nonce: &str, total: u64) -> Option { + let nonce = norm_nonce(nonce); + let (hash, daa) = match self.pending.iter().position(|p| !nonce.is_empty() && p.0 == nonce) { + Some(i) => { + let p = self.pending.remove(i); + (p.1, p.2) + } + None => (String::new(), 0), + }; + let day = day_of(now); + match self.days.iter_mut().find(|d| d.day == day) { + Some(d) => d.blocks += 1, + None => self.days.push(DayBlocks { day, blocks: 1 }), + } + self.days.sort_by_key(|d| d.day); + if self.days.len() > KEEP_DAYS as usize { + let n = self.days.len() - KEEP_DAYS as usize; + self.days.drain(0..n); + } + if self.first_block_at == 0.0 { + self.first_block_at = now; + self.first_block_card = card.to_string(); + self.first_block_hash = hash.clone(); + self.first_block_daa = daa; + } + let new_card = !card.is_empty() && !self.cards_first.contains_key(card); + if new_card { + self.cards_first.insert(card.to_string(), now); + } + self.blocks.insert(0, BlockFound { hash: hash.clone(), daa, nonce, at: now, card: card.to_string(), card_name: card_name.to_string(), count: total }); + self.blocks.truncate(KEEP_BLOCKS); + let kind = if total == 1 { "first" } else if total == 100 { "hundred" } else if total == 1_000 { "thousand" } else if total >= 10_000 && total % 10_000 == 0 { "ten_thousand" } else if new_card { "card" } else { "" }; + if kind.is_empty() { + return None; + } + let m = Milestone { kind: kind.into(), count: total, card: card.into(), card_name: card_name.into(), hash, daa, at: now }; + self.milestone = Some(m.clone()); + Some(m) + } + + /// A `VOTE index=N` line from a miner: the streak holds while votes keep coming inside `gap_s`. + pub fn on_vote(&mut self, now: f64, index: u64, gap_s: f64) { + self.votes_signed += 1; + if self.streak_since == 0.0 || (self.last_vote_at > 0.0 && now - self.last_vote_at > gap_s) { + self.streak_since = now; + } + self.last_vote_at = now; + if index > self.last_vote_index { + self.last_vote_index = index; + } + if index > 0 && !self.signed_open.contains(&index) { + self.signed_open.push(index); + if self.signed_open.len() > KEEP_SIGNED { + let n = self.signed_open.len() - KEEP_SIGNED; + self.signed_open.drain(0..n); + } + } + } + + /// A `LOCK checkpoint N` line: every signed index at or under N is in a locked certificate now. + pub fn on_lock(&mut self, index: u64) { + let (locked, open): (Vec, Vec) = self.signed_open.iter().partition(|&&i| i <= index); + if let Some(&top) = locked.iter().max() { + if top > self.last_signed_locked { + self.last_signed_locked = top; + } + self.signed_locked_count += locked.len() as u64; + } + self.signed_open = open; + } + + /// The timeline's first-time marks ("synced", "mining"): set once, kept. Returns true when it was new. + pub fn mark(&mut self, what: &str, now: f64) -> bool { + let slot = match what { "synced" => &mut self.first_synced_at, "mining" => &mut self.first_mining_at, _ => return false }; + if *slot > 0.0 { return false; } + *slot = now; + true + } + + pub fn on_shard_paid(&mut self, now: f64, block: u64, shard: u64, wei: u128) { + if self.shards.iter().any(|s| s.block == block && s.shard == shard) { + return; + } + self.shards.insert(0, ShardPaid { block, shard, wei: wei.to_string(), at: now }); + self.shards.truncate(KEEP_SHARDS); + } + + /// Seconds of unbroken signing at `now` (0 when the last vote is older than `gap_s`). + pub fn streak_s(&self, now: f64, gap_s: f64) -> f64 { + if self.streak_since == 0.0 || self.last_vote_at == 0.0 || now - self.last_vote_at > gap_s { + return 0.0; + } + (now - self.streak_since).max(0.0) + } + + fn blocks_since(&self, from: f64) -> u64 { + // the day ring is daily; the finer windows read the block list and fall back to the ring + self.days.iter().filter(|d| (d.day as f64 + 1.0) * DAY_S > from).map(|d| d.blocks).sum() + } + + pub fn state(&self, now: f64, gap_s: f64) -> LadderState { + let today = day_of(now); + let window_from = now - 30.0 * DAY_S; + let days_mined_30 = self.days.iter().filter(|d| d.blocks > 0 && (d.day as f64 + 1.0) * DAY_S > window_from).count() as u64; + LadderState { + first_block_at: self.first_block_at, + first_block_card: self.first_block_card.clone(), + first_block_hash: self.first_block_hash.clone(), + first_block_daa: self.first_block_daa, + days_mined_30, + blocks_30d: self.blocks_since(window_from), + blocks_today: self.days.iter().find(|d| d.day == today).map(|d| d.blocks).unwrap_or(0), + days: self.days.iter().filter(|d| (d.day as f64 + 1.0) * DAY_S > window_from).cloned().collect(), + cards_first: self.cards_first.clone(), + blocks: self.blocks.iter().take(10).cloned().collect(), + votes_signed: self.votes_signed, + last_vote_index: self.last_vote_index, + last_vote_at: self.last_vote_at, + last_signed_locked: self.last_signed_locked, + signed_locked_count: self.signed_locked_count, + streak_s: self.streak_s(now, gap_s), + shards: self.shards.clone(), + milestone: self.milestone.clone(), + first_synced_at: self.first_synced_at, + first_mining_at: self.first_mining_at, + } + } +} + +/// The Activity line a milestone raises (the dashboard's EVENT_MAP reads these shapes). +pub fn milestone_event(m: &Milestone) -> String { + match m.kind.as_str() { + "first" => format!("first block: found by {} (block card)", m.card_name), + "hundred" => format!("block 100 of this machine: found by {} (block card)", m.card_name), + "thousand" => format!("block 1,000 of this machine: found by {} (block card)", m.card_name), + "ten_thousand" => format!("block {} of this machine: found by {} (block card)", with_commas(m.count), m.card_name), + _ => format!("first block on {} (block card)", m.card_name), + } +} + +fn with_commas(n: u64) -> String { + let s = n.to_string(); + let mut out = String::new(); + for (i, c) in s.chars().enumerate() { + if i > 0 && (s.len() - i) % 3 == 0 { out.push(','); } + out.push(c); + } + out +} + +fn norm_nonce(n: &str) -> String { + let t = n.trim().trim_start_matches("0x").trim_start_matches('0').to_ascii_lowercase(); + if t.is_empty() && !n.trim().is_empty() { "0".into() } else { t } +} + +/// `PoW accepted by (daa N, ..., nonce 0x..)` -> (hash, daa, nonce) +pub fn parse_node_accepted(text: &str) -> Option<(String, u64, String)> { + let rest = text.split("PoW accepted ").nth(1)?; + let hash = rest.split_whitespace().next()?.to_string(); + if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) { + return None; + } + let daa = rest.split("(daa ").nth(1)?.split(|c: char| !c.is_ascii_digit()).next()?.parse::().ok()?; + let nonce = rest.split("nonce ").nth(1)?.trim_end_matches(')').split_whitespace().next()?.to_string(); + Some((hash, daa, nonce)) +} + +/// `... ACCEPTED block nonce=0x.. (...)` -> the nonce +pub fn parse_miner_accepted(text: &str) -> Option { + let rest = text.split("ACCEPTED block nonce=").nth(1)?; + Some(rest.split_whitespace().next()?.to_string()) +} + +/// `... VOTE index=N ...` -> N +pub fn parse_vote_index(text: &str) -> Option { + text.split("VOTE index=").nth(1)?.split(|c: char| !c.is_ascii_digit()).next()?.parse().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + const NODE: &str = "2026-10-04 20:08:38.486+00:00 [INFO ] PoW accepted 28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce by igneum-lottery-v2-bound (daa 38637, epoch seed c8b574fbbbd6ba93b34f8c9a81042b6970b140ffb24219e381d82b653673ee81, day 20730, nonce 0x267dd27200a91c80)"; + const MINER: &str = "1791140918.500 ACCEPTED block nonce=0x267dd27200a91c80 (gpu worker, cpu re-check ok, identity mac-01234567-1)"; + + #[test] + fn the_node_and_miner_lines_parse() { + let (h, daa, n) = parse_node_accepted(NODE).unwrap(); + assert_eq!(h, "28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce"); + assert_eq!(daa, 38637); + assert_eq!(n, "0x267dd27200a91c80"); + assert_eq!(parse_miner_accepted(MINER).unwrap(), "0x267dd27200a91c80"); + assert_eq!(parse_vote_index("1791140918.500 VOTE index=1240 checkpoint=abcd signed").unwrap(), 1240); + assert!(parse_node_accepted("PoW rejected 00 by x").is_none()); + } + + #[test] + fn the_first_block_is_matched_to_its_hash_by_nonce_and_raises_the_first_card() { + let mut l = Ladder::default(); + let (h, daa, n) = parse_node_accepted(NODE).unwrap(); + l.on_node_accepted(&h, daa, &n); + // a relayed block from another miner sits in pending too and never matches + l.on_node_accepted("ef21a8454b2f3fcaf6c32a8b9c959df936770edfdffff61c7198989d9431ea4b", 38643, "0x4bc93aba007eabaa"); + let m = l.on_block(1_791_140_918.5, "apple::Apple M5 Max", "Apple M5 Max", &parse_miner_accepted(MINER).unwrap(), 1).unwrap(); + assert_eq!(m.kind, "first"); + assert_eq!(m.hash, h); + assert_eq!(m.daa, 38637); + assert_eq!(l.first_block_hash, h); + assert_eq!(l.first_block_daa, 38637); + assert_eq!(l.first_block_card, "apple::Apple M5 Max"); + assert_eq!(l.blocks.len(), 1); + assert_eq!(l.pending.len(), 1, "the relayed block stays unmatched"); + let s = l.state(1_791_140_920.0, 600.0); + assert_eq!(s.days_mined_30, 1); + assert_eq!(s.blocks_30d, 1); + assert_eq!(s.blocks_today, 1); + } + + #[test] + fn a_miner_line_before_the_node_line_gets_its_hash_filled_in() { + let mut l = Ladder::default(); + l.on_block(100.0, "c", "Card", "0x267dd27200a91c80", 1); + assert_eq!(l.first_block_hash, ""); + let (h, daa, n) = parse_node_accepted(NODE).unwrap(); + l.on_node_accepted(&h, daa, &n); + assert_eq!(l.blocks[0].hash, h); + assert_eq!(l.first_block_hash, h); + assert_eq!(l.milestone.as_ref().unwrap().hash, h); + assert_eq!(l.milestone.as_ref().unwrap().daa, 38637); + } + + #[test] + fn the_cards_come_at_1_100_1000_10000_and_on_a_new_card() { + let mut l = Ladder::default(); + let mut kinds = vec![]; + for total in 1..=1_000u64 { + if let Some(m) = l.on_block(1000.0 + total as f64, "a", "A", "", total) { kinds.push((total, m.kind)); } + } + assert_eq!(kinds, vec![(1, "first".to_string()), (100, "hundred".to_string()), (1000, "thousand".to_string())]); + assert_eq!(l.on_block(5000.0, "b", "B", "", 1001).unwrap().kind, "card"); + assert!(l.on_block(5001.0, "b", "B", "", 1002).is_none()); + assert_eq!(l.on_block(5002.0, "a", "A", "", 10_000).unwrap().kind, "ten_thousand"); + assert_eq!(l.on_block(5003.0, "a", "A", "", 20_000).unwrap().kind, "ten_thousand"); + assert_eq!(l.blocks.len(), KEEP_BLOCKS); + } + + #[test] + fn days_mined_counts_distinct_days_inside_the_window_only() { + let mut l = Ladder::default(); + let now = 40.0 * DAY_S + 100.0; + // blocks on days 5, 20, 20, 39 and 40 (today); day 5 is outside the 30-day window + for (d, total) in [(5.0, 1), (20.0, 2), (20.0, 3), (39.0, 4), (40.0, 5)] { + l.on_block(d * DAY_S + 50.0, "a", "A", "", total); + } + let s = l.state(now, 600.0); + assert_eq!(s.days_mined_30, 3); + assert_eq!(s.blocks_30d, 4); + assert_eq!(s.blocks_today, 1); + assert_eq!(s.days.len(), 3); + } + + #[test] + fn the_signing_streak_holds_across_votes_and_breaks_on_a_gap() { + let mut l = Ladder::default(); + l.on_vote(1000.0, 10, 600.0); + l.on_vote(1030.0, 11, 600.0); + l.on_vote(1060.0, 12, 600.0); + assert_eq!(l.streak_s(1090.0, 600.0), 90.0); + assert_eq!(l.votes_signed, 3); + assert_eq!(l.last_vote_index, 12); + // the lock at 11 puts 10 and 11 in locked certificates; 12 stays open + l.on_lock(11); + assert_eq!(l.last_signed_locked, 11); + assert_eq!(l.signed_locked_count, 2); + assert_eq!(l.signed_open, vec![12]); + // a gap longer than the presence window ends the streak; the next vote starts a new one + assert_eq!(l.streak_s(1700.0, 600.0), 0.0); + l.on_vote(1700.0, 33, 600.0); + assert_eq!(l.streak_s(1730.0, 600.0), 30.0); + assert_eq!(l.state(1730.0, 600.0).streak_s, 30.0); + } + + #[test] + fn the_milestone_events_read_in_the_users_words() { + let m = |kind: &str, count: u64| Milestone { kind: kind.into(), count, card: "a".into(), card_name: "RTX 4070".into(), hash: String::new(), daa: 0, at: 0.0 }; + assert_eq!(milestone_event(&m("first", 1)), "first block: found by RTX 4070 (block card)"); + assert_eq!(milestone_event(&m("hundred", 100)), "block 100 of this machine: found by RTX 4070 (block card)"); + assert_eq!(milestone_event(&m("ten_thousand", 20_000)), "block 20,000 of this machine: found by RTX 4070 (block card)"); + assert_eq!(milestone_event(&m("card", 7)), "first block on RTX 4070 (block card)"); + } + + #[test] + fn the_timeline_marks_are_set_once() { + let mut l = Ladder::default(); + assert!(l.mark("synced", 10.0)); + assert!(!l.mark("synced", 20.0)); + assert!(l.mark("mining", 30.0)); + assert!(!l.mark("other", 40.0)); + let s = l.state(50.0, 600.0); + assert_eq!(s.first_synced_at, 10.0); + assert_eq!(s.first_mining_at, 30.0); + } + + #[test] + fn paid_shards_are_kept_once_each_newest_first() { + let mut l = Ladder::default(); + l.on_shard_paid(10.0, 1_284_117, 3, 1_150_000_000_000_000_000); + l.on_shard_paid(11.0, 1_284_117, 3, 1_150_000_000_000_000_000); + l.on_shard_paid(12.0, 1_284_120, 0, 2_000_000_000_000_000_000); + assert_eq!(l.shards.len(), 2); + assert_eq!(l.shards[0].block, 1_284_120); + assert_eq!(l.shards[1].wei, "1150000000000000000"); + } + + #[test] + fn the_record_survives_a_save_and_load() { + let mut l = Ladder::default(); + l.on_block(100.0, "a", "A", "0x1", 1); + l.on_vote(130.0, 5, 600.0); + let dir = std::env::temp_dir().join(format!("igneum-ladder-test-{}", std::process::id())); + let path = dir.join("ladder.json"); + l.save(&path); + let back = Ladder::load(&path); + assert_eq!(back.first_block_at, 100.0); + assert_eq!(back.votes_signed, 1); + assert_eq!(back.blocks, l.blocks); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(Ladder::load(&dir.join("missing.json")).votes_signed, 0); + } +} diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index b04ad5f2b..f2a40b61d 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -40,6 +40,9 @@ mod watchdog; mod live; mod extnode; mod merge; +mod ladder; +mod chainfacts; +mod card; use std::io::{BufRead, Write}; use std::sync::mpsc::channel; diff --git a/app/igneum-app/src/platform.rs b/app/igneum-app/src/platform.rs index 55bfd2fae..42e67793d 100644 --- a/app/igneum-app/src/platform.rs +++ b/app/igneum-app/src/platform.rs @@ -206,6 +206,17 @@ pub fn open_url(url: &str) { let _ = Command::new("xdg-open").arg(url).spawn(); } +/// Shows a file in the system's file browser (the saved block card): Finder with the file selected, Explorer with +/// the file selected, the folder on other systems. +pub fn reveal_file(path: &std::path::Path) { + #[cfg(target_os = "macos")] + let _ = Command::new(tool("open")).arg("-R").arg(path).spawn(); + #[cfg(windows)] + let _ = quiet(&mut Command::new(tool("explorer"))).arg(format!("/select,{}", path.display())).spawn(); + #[cfg(not(any(target_os = "macos", windows)))] + let _ = Command::new("xdg-open").arg(path.parent().unwrap_or(path)).spawn(); +} + /// Keeps the machine awake while the engine runs. macOS: caffeinate tied to this process. Windows: the execution state, /// which must be refreshed (call `keep_awake_tick` every minute). pub struct KeepAwake { diff --git a/app/igneum-app/src/prover.rs b/app/igneum-app/src/prover.rs index 1d2140bbc..cd67296ac 100644 --- a/app/igneum-app/src/prover.rs +++ b/app/igneum-app/src/prover.rs @@ -515,6 +515,7 @@ fn loop_forever(shared: Arc, bin_dir: PathBuf) { if paid { submitted.retain(|x| !(x.1 == h && x.2 == s)); shared.event("proving", &format!("block {n} shard {s} paid {} IGN", wei as f64 / 1e18)); + shared.ladder_shard_paid(n, s as u64, wei); set(&shared, |p| { p.paid += 1; p.paid_wei += wei; diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index 2be34c31a..75d0c1f6d 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -13,6 +13,8 @@ const CSS: &str = include_str!("../ui/app.css"); const JS: &str = include_str!("../ui/app.js"); const MARK: &str = include_str!("../ui/mark.svg"); const DAG_JS: &str = include_str!("../ui/live-dag.js"); +/// the renderer pack's proof visual (EMBER 02, the site lane's byte-identical copy; miner-ui-5) +const PROOF_JS: &str = include_str!("../ui/proof-core.js"); const COIN: &[u8] = include_bytes!("../../../brand/igneum-coin-1024.png"); const FONT_MONO_400: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-400.woff2"); const FONT_MONO_500: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-500.woff2"); @@ -176,6 +178,7 @@ fn handle(mut stream: TcpStream, shared: Arc) { ("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false), ("GET", "/mark.svg") => respond(&mut stream, 200, "image/svg+xml", MARK.as_bytes(), true), ("GET", "/live-dag.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", DAG_JS.as_bytes(), false), + ("GET", "/proof-core.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", PROOF_JS.as_bytes(), false), ("GET", "/coin.png") => respond(&mut stream, 200, "image/png", COIN, true), ("GET", "/fonts/IBMPlexMono-400.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_400, true), ("GET", "/fonts/IBMPlexMono-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_500, true), @@ -196,6 +199,13 @@ fn handle(mut stream: TcpStream, shared: Arc) { let ids: std::collections::HashSet = shared.state.lock().unwrap().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect(); json_resp(&mut stream, 200, crate::live::fetch(&live_api, window, &ids)); } + // miner-ui-5: the ladder's chain facts (src/chainfacts.rs): the node's getFinalityWeights through the local + // node when it answers igneum_getFinalityWeights, else the observer's relay; cached 10 s + ("GET", "/api/ladder") => { + let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page)); + let ids: Vec = shared.state.lock().unwrap().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect(); + json_resp(&mut stream, 200, crate::chainfacts::fetch(shared.runtime.evm_port(), &live_api, &ids)); + } ("GET", "/api/log") => { let after = query_param(&req.query, "after").and_then(|s| s.parse().ok()).unwrap_or(0u64); let limit = query_param(&req.query, "limit").and_then(|s| s.parse().ok()).unwrap_or(400usize).min(2000); @@ -293,7 +303,8 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result shared.set_prove(body.get("on").and_then(|v| v.as_bool()).unwrap_or(false)), "/api/prove/setup" => crate::prover::setup(shared), @@ -381,6 +392,18 @@ fn api_post(shared: &Arc, path: &str, body: Value) -> Result", beside URL, STATE and EXIT) and the host writes a PNG of // what it shows to that path under /shots/, where a collect job's glob "shots/*.png" picks it up. // Read-only: nothing about mining changes. A caller without a host (a bare engine) gets the path and no file. + // miner-ui-5: the block card's PNG, drawn by the page on a canvas (no network call), written under + // /cards/ and the folder opened for the user. The body is {name, png: "data:image/png;base64,..."}. + "/api/card" => { + let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("block"); + let png = body.get("png").and_then(|v| v.as_str()).ok_or("png missing")?; + let bytes = crate::card::decode_data_url(png).ok_or("the png is not a base64 data URL")?; + let path = crate::card::card_path(&crate::platform::data_root(), name, crate::platform::unix_now()); + if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } + std::fs::write(&path, &bytes).map_err(|e| format!("could not write the card: {e}"))?; + if body.get("reveal").and_then(|v| v.as_bool()).unwrap_or(true) { crate::platform::reveal_file(&path); } + Ok(json!({ "ok": true, "path": path.display().to_string(), "bytes": bytes.len() })) + } "/api/shot" => { let name = body.get("name").and_then(|v| v.as_str()).unwrap_or(""); let path = shot_path(&crate::platform::data_root(), name, crate::platform::unix_now()); diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index aba451b40..e746ae3ef 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -330,6 +330,8 @@ pub struct SettingsState { pub dev_fee: bool, /// devnet only: the node trusts proof records without a verifier (`IGNEUM_PROOF_VERIFY=trust`) pub proof_verify_trust: bool, + /// miner-ui-5: the public address profile opt-in (settings.profile_public) + pub profile_public: bool, } /// One remote job this machine ran (the ledger entry), for the Settings history and the last-job strip. @@ -447,6 +449,8 @@ pub struct State { pub now: f64, pub quitting: bool, pub live_page: String, + /// miner-ui-5: this machine's own ladder record (src/ladder.rs), summarised at `now` + pub ladder: crate::ladder::LadderState, } /// Ring buffers behind the state: events (newest first in the JSON) and the log drawer.