Merge commit 'e22d2425' into release-0.3.19

This commit is contained in:
igneum-labs 2026-10-07 09:39:34 +00:00
commit 16bd5f7347
35 changed files with 2428 additions and 254 deletions

View file

@ -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
//! `<data root>/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<Vec<u8>> {
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<Vec<u8>> {
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)
}
/// `<root>/cards/igneum-block-<name>-<unix>.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::<String>().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");
}
}

View file

@ -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<Option<(Instant, Value)>> = 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<Value> = 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<Value> = 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<Value> {
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(""), "");
}
}

View file

@ -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 }
}
}

View file

@ -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), `<app dir>/ladder.json`
pub ladder: Mutex<crate::ladder::Ladder>,
pub ladder_path: PathBuf,
}
impl Shared {
pub fn new(token: String, runtime: Runtime, packaged: Packaged, settings: Settings, cmd_tx: Sender<Cmd>, engine_log: Option<std::fs::File>, 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=<v> machine=<id8> platform=<os> node=<igneumd version>. 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<u32>, vote: Option<bool>, login: Option<bool>, address: Option<&str>, display_name: Option<&str>, dev_fee: Option<bool>, proof_verify_trust: Option<bool>) -> Result<Value, String> {
pub fn apply_settings(&self, identities: Option<u32>, vote: Option<bool>, login: Option<bool>, address: Option<&str>, display_name: Option<&str>, dev_fee: Option<bool>, proof_verify_trust: Option<bool>, profile_public: Option<bool>) -> Result<Value, String> {
let mut restart = Vec::new();
let mut restart_node: Option<String> = 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<String> = 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"));

View file

@ -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 `<app dir>/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 <hash> 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<DayBlocks>,
/// card key -> unix s of its first block
#[serde(default)]
pub cards_first: BTreeMap<String, f64>,
#[serde(default)]
pub blocks: Vec<BlockFound>,
#[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<u64>,
#[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<ShardPaid>,
#[serde(default)]
pub milestone: Option<Milestone>,
/// 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<DayBlocks>,
pub cards_first: BTreeMap<String, f64>,
pub blocks: Vec<BlockFound>,
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<ShardPaid>,
pub milestone: Option<Milestone>,
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<Milestone> {
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<u64>, Vec<u64>) = 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 <hash> by <engine> (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::<u64>().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<String> {
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<u64> {
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);
}
}

View file

@ -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;

View file

@ -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 {

View file

@ -525,6 +525,7 @@ fn loop_forever(shared: Arc<Shared>, 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;

View file

@ -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<Shared>) {
("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<Shared>) {
let ids: std::collections::HashSet<String> = 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<String> = 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<Shared>, path: &str, body: Value) -> Result<Value, Stri
let display_name = s("display_name");
let dev_fee = body.get("dev_fee").and_then(|v| v.as_bool());
let proof_verify_trust = body.get("proof_verify_trust").and_then(|v| v.as_bool());
shared.apply_settings(identities, vote, login, address.as_deref(), display_name.as_deref(), dev_fee, proof_verify_trust)
let profile_public = body.get("profile_public").and_then(|v| v.as_bool());
shared.apply_settings(identities, vote, login, address.as_deref(), display_name.as_deref(), dev_fee, proof_verify_trust, profile_public)
}
"/api/prove" => 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<Shared>, path: &str, body: Value) -> Result<Value, Stri
// engine asks it over the host protocol ("SHOT <path>", beside URL, STATE and EXIT) and the host writes a PNG of
// what it shows to that path under <data root>/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
// <data root>/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());

View file

@ -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.

View file

@ -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)}}

View file

@ -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 '<i class="' + esc(x.state) + '"></i>'; }).join('');
$('i-shards').innerHTML = sh.length ? sh.map(function (x, i) { return '<li class="' + esc(x.state) + '"><span>Shard ' + String(i + 1).padStart(2, '0') + '</span><span>' + esc(x.state.charAt(0).toUpperCase() + x.state.slice(1)) + '</span></li>'; }).join('') : '<li class="muted">' + (b.chain ? 'No shard plan yet.' : 'Not a chain block: no shards.') + '</li>';
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 = '<li class="muted">No block selected.</li>';
['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 '<li class="rung ' + esc(r.state) + '" title="' + esc(r.field) + '"><span class="rg-dot" aria-hidden="true"></span><span class="rg-body"><span class="rg-name">' + esc(r.name) + '</span><span class="rg-line">' + esc(r.line) + '</span>' + (r.sub ? '<span class="rg-sub">' + esc(r.sub) + '</span>' : '') + (r.progress >= 0 && r.state !== 'done' ? '<i class="bar"><b style="width:' + Math.round(r.progress * 100) + '%"></b></i>' : '') + '</span></li>';
}
// 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 '<span class="ls-rung ' + esc(r.state) + (r.id === cur.id ? ' cur' : '') + '" role="listitem" title="' + esc(r.line + ' — ' + r.field).replace(/—/, '·') + '"><i></i><span>' + esc(r.name) + '</span></span>'; }).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 '<li class="' + (x.at > 0 ? 'done' : '') + '"><span class="tl-name">' + esc(x.name) + '</span><span class="tl-at mono">' + esc(x.at > 0 ? (x.rel || clock(x.at)) : '') + '</span>' + (x.note ? '<span class="tl-note dim">' + esc(x.note) + '</span>' : '') + '</li>'; }).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 '<div class="num ' + cls + '"><span class="v' + (tone ? ' ' + tone : '') + '">' + esc(v) + (unit ? '<span class="unit">' + unit + '</span>' : '') + '</span>' + (k ? '<span class="k">' + esc(k) + '</span>' : '') + '</div>'; }
function numCell(cls, v, unit, k, tone, title) { return '<div class="num ' + cls + '"' + (title ? ' title="' + esc(title) + '"' : '') + '><span class="v' + (tone ? ' ' + tone : '') + '">' + esc(v) + (unit ? '<span class="unit">' + unit + '</span>' : '') + '</span>' + (k ? '<span class="k">' + esc(k) + '</span>' : '') + '</div>'; }
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 += '<div class="num money"><button class="btn tiny ghost" data-price="1">Set a price</button><span class="k">£ a day</span></div>';
@ -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('') : '<div class="empty' + (s.detecting ? '' : ' err') + '">' + (s.detecting ? 'Asking the graphics cards to report in.' : 'No graphics card this app can use was found. The node runs on its own.') + '</div>';
@ -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 () {

View file

@ -185,15 +185,73 @@
</div>
</div>
<!-- miner-ui-5: before the first block, the Poisson count-up; on a milestone, the block card (in place, never a dialog) -->
<div class="card wait-card" id="first-wait" hidden>
<div class="wait-row"><span class="wait-ring" aria-hidden="true"><svg viewBox="0 0 36 36"><circle class="track" cx="18" cy="18" r="15.5"></circle><circle class="arc" id="first-wait-arc" cx="18" cy="18" r="15.5"></circle></svg><span class="pct mono" id="first-wait-pct">0%</span></span>
<div class="wait-text"><div class="t" id="first-wait-line">Waiting for your first block.</div><div class="s" id="first-wait-sub"></div></div>
</div>
</div>
<div class="card block-card" id="block-card" hidden>
<div class="bc-head"><span class="eyebrow ember" id="bc-eyebrow">your first block</span><span class="bc-when mono" id="bc-when"></span><button class="notice-close bc-close" id="bc-close" title="Dismiss" aria-label="Dismiss the block card">&times;</button></div>
<h2 class="bc-title" id="bc-title">Block 0 is yours.</h2>
<p class="bc-line" id="bc-card"></p>
<p class="bc-line" id="bc-ign"></p>
<p class="bc-line dim" id="bc-rank"></p>
<p class="bc-hash mono" id="bc-hash"></p>
<div class="row wrap"><button class="btn small primary" id="bc-open">Open in the explorer</button><button class="btn small" id="bc-save">Save the card</button><button class="btn small ghost" id="bc-copy">Copy the link</button><span class="note mono small" id="bc-saved"></span></div>
<canvas id="bc-canvas" width="1200" height="630" hidden aria-hidden="true"></canvas>
</div>
<div class="card ladder-strip" id="ladder-strip" hidden>
<div class="ls-rungs" id="ls-rungs" role="list" aria-label="The ladder"></div>
<div class="ls-line"><span class="t" id="ls-line"></span><span class="s dim" id="ls-sub"></span><button class="btn tiny ghost" id="ls-more">The ladder</button></div>
</div>
<div class="card dag-card" id="dag-card">
<div class="card-head"><h3>The chain, live</h3><div class="head-right"><span class="eyebrow" id="dag-state">connecting</span><button class="btn tiny ghost" id="dag-pause" aria-pressed="false">Pause</button></div></div>
<div class="card-head"><h3>The chain, live</h3><div class="head-right"><span class="eyebrow" id="dag-state">connecting</span><button class="btn tiny ghost" id="dag-inspect" aria-expanded="false" aria-controls="chain-full">Inspect</button><button class="btn tiny ghost" id="dag-pause" aria-pressed="false">Pause</button></div></div>
<!-- the compact scene (EMBER 02 live-dag.js, the site lane's module, byte-identical): the app feeds it its own api/live reply -->
<div class="dag-wrap">
<canvas id="dag-live" aria-label="The last two minutes of blocks: your row and the other miners, the selected chain as a lit path, checkpoints as bands"></canvas>
<canvas id="dag-live" aria-label="The last minute of blocks: your lane and the other miner keys, the selected chain as one path, checkpoints as bands, your blocks framed"></canvas>
<div class="dag-tip mono" id="dag-tip" hidden></div>
<span class="dag-chip mono" id="dag-chip" hidden>scroll to zoom · Esc to release</span>
<canvas id="dag" aria-hidden="true" hidden></canvas>
</div>
<div class="legend"><span><i class="lg you"></i>yours</span><span><i class="lg pending"></i>pending</span><span><i class="lg included"></i>included</span><span><i class="lg excluded"></i>excluded</span><span><i class="lg proven"></i>proven</span><span><i class="lg locked"></i>locked checkpoint</span><span class="dim" id="dag-note"></span></div>
<div class="legend"><span><i class="lg you"></i>Your blocks</span><span><i class="lg pending"></i>pending</span><span><i class="lg included"></i>Included</span><span><i class="lg excluded"></i>Excluded</span><span><i class="lg proven"></i>Proven</span><span><i class="lg locked"></i>Locked checkpoint</span><span class="dim" id="dag-note"></span></div>
<!-- the full scene with the block inspector (the words of the site's /live): opened by Inspect or by a click on a block -->
<div class="chain-full" id="chain-full" hidden>
<div class="cf-bar">
<div class="seg small" id="dag-filter" role="radiogroup" aria-label="Which blocks"><button class="seg-b on" data-filter="all" role="radio" aria-checked="true">All blocks</button><button class="seg-b" data-filter="chain" role="radio" aria-checked="false">Selected chain</button><button class="seg-b" data-filter="mine" role="radio" aria-checked="false">Your blocks</button></div>
<span class="cf-right"><span class="zoom"><button type="button" id="z-out" aria-label="Wider window">&minus;</button><span id="z-pct" class="mono">60 s</span><button type="button" id="z-in" aria-label="Narrower window">+</button></span><button class="btn tiny ghost on" id="dag-follow">Follow</button><button class="btn tiny ghost" id="dag-pause-full" aria-pressed="false">Pause</button></span>
</div>
<div class="cf-graph">
<div class="cf-scene">
<canvas id="dag-full" aria-label="The devnet's block graph, live: time-aligned miner lanes, every parent connection, the selected chain as one path, checkpoint bands, your blocks framed"></canvas>
<div class="dag-tip mono" id="dag-tip-full" hidden></div>
<span class="dag-chip mono" id="dag-chip-full" hidden>scroll to zoom · Esc to release</span>
<div class="cf-foot mono">Click to inspect &nbsp;&middot;&nbsp; Drag to explore</div>
</div>
<aside class="inspector" id="inspector" aria-live="polite">
<div class="ih"><span class="eyebrow">Block inspector</span><span class="chip mono" id="i-kind">select a block</span></div>
<h3 class="ititle" id="i-title">Select a block</h3>
<div class="ihash mono" id="i-hash">Click the graph.</div>
<div class="proof-scene"><canvas id="proof" aria-label="Select a block to see its shards"></canvas></div>
<div class="proof-head"><span>Proof shards</span><span id="i-shard-count" class="mono">not set</span></div>
<div class="track" id="i-track"></div>
<ul class="shards mono" id="i-shards"><li class="muted">No block selected.</li></ul>
<dl class="insp mono">
<dt>Inclusion</dt><dd id="i-incl">not set</dd>
<dt>Miner key</dt><dd id="i-miner">not set</dd>
<dt>Blue score</dt><dd id="i-blue">not set</dd>
<dt>DAA score</dt><dd id="i-daa">not set</dd>
<dt>Parent links</dt><dd id="i-parents">not set</dd>
<dt>Header time</dt><dd id="i-time">not set</dd>
<dt>Checkpoint</dt><dd id="i-cp">not set</dd>
<dt>Finality</dt><dd id="i-lock">not set</dd>
</dl>
<p class="note">Proof, chain selection and finality are separate states. None is inferred from a block&rsquo;s age; a lock is drawn only when the observer reports one.</p>
<p class="top-gap"><button class="btn tiny ghost" id="insp-close" hidden>Clear</button></p>
</aside>
</div>
</div>
</div>
<div class="card node-card" id="node-card">
@ -265,16 +323,35 @@
<!-- Earnings -->
<section class="page" id="page-earnings" data-page="earnings" hidden>
<div class="card">
<!-- miner-ui-5 (reinvent 3.4): IGN first, the window, a typed price, the share and the rank, electricity with IGN per kWh.
Every number names its source on hover (title). -->
<div class="money">
<div class="money-row"><span class="v" id="e-earned">£0.00</span><span class="s" id="e-earned-sub">earned: nothing is bought or sold on devnet</span></div>
<div class="money-row"><span class="v small" id="e-ign">0.0000 IGN</span><span class="s" id="e-ign-sub">from 0 proofs</span></div>
<div class="money-row"><span class="v small" id="e-blocks">0 blocks</span><span class="s" id="e-blocks-sub">lifetime</span></div>
<div class="money-row"><span class="v small" id="e-cost">£0.00 a day</span><span class="s" id="e-cost-sub">electricity</span><button class="btn tiny ghost" id="e-price-btn">Set the price</button></div>
<div class="money-row"><span class="v" id="e-day">0 IGN a day</span><span class="s" id="e-day-sub">reading the network</span></div>
<div class="money-row"><span class="v small" id="e-window">0 blocks in 30 days</span><span class="s" id="e-window-sub"></span></div>
<div class="money-row"><span class="v tiny" id="e-earned">not read yet</span><span class="s" id="e-earned-sub">balance: the node answers once it is synced</span></div>
<div class="money-row price-row"><span class="v small" id="e-price">Type a price</span><span class="s" id="e-price-sub"></span><span class="row"><span class="mono dim">£</span><input type="number" class="addr-input mono short" id="e-price-input" min="0" step="0.0001" placeholder="0.00" aria-label="Your price for one IGN in pounds"><button class="btn tiny" id="e-price-save">Use it</button></span></div>
<div class="money-row"><span class="v tiny" id="e-share">reading</span><span class="s" id="e-share-sub"></span></div>
<div class="money-row"><span class="v small" id="e-cost">£0.00 a day</span><span class="s" id="e-cost-sub">electricity</span><span class="s" id="e-kwh"></span><button class="btn tiny ghost" id="e-price-btn">Set the price</button></div>
<div class="money-row"><span class="v small" id="e-blocks">0 blocks</span><span class="s" id="e-blocks-sub">lifetime</span><span class="s" id="e-ign-sub"></span></div>
</div>
<label class="switch"><input type="checkbox" id="s-devfee"><span class="track"></span><span class="sw-text" id="s-devfee-text">Dev fee: 1 block in 100 pays the people who make this app</span></label>
<p class="help" id="r-devfee-line"></p>
</div>
<div class="card" id="ladder-card">
<div class="card-head"><h3>The ladder</h3><div class="head-right"><span class="eyebrow" id="ladder-source">reading</span></div></div>
<p class="help">Weight is your blocks over the window, written into consensus. No points server, no badge: the chain computes every rung and your node reports it. Hover a line for the field it reads.</p>
<ol class="rungs" id="rungs"></ol>
</div>
<div class="card" id="timeline-card">
<button class="disclose" id="tl-toggle" aria-expanded="false" aria-controls="tl-details"><span>Your first hour</span><span class="disclose-right"><span class="dim" id="tl-sum"></span><span class="chev" aria-hidden="true"></span></span></button>
<div class="details" id="tl-details" hidden>
<ol class="timeline" id="tl-steps"></ol>
<p class="note" id="tl-note"></p>
</div>
</div>
<div class="card">
<div class="card-head"><h3>Rewards address</h3><div class="eyebrow" id="r-source"></div></div>
<div class="addr-big mono"><span id="r-address">not set</span><button class="btn small" data-copy="r-address">Copy</button></div>
@ -312,6 +389,12 @@
<!-- Prove -->
<section class="page" id="page-prove" data-page="prove" hidden>
<div class="card shard-card" id="shard-card" hidden>
<div class="bc-head"><span class="eyebrow ember">proof shards</span><span class="bc-when mono" id="sc-when"></span></div>
<h2 class="bc-title small" id="sc-title">Your card proved shard 0 of block 0.</h2>
<p class="bc-line" id="sc-line"></p>
<p class="bc-line dim" id="sc-sub"></p>
</div>
<div class="card">
<div class="lead-row">
<div class="lead-text">
@ -371,6 +454,7 @@
<div class="card">
<div class="card-head"><h3>This machine</h3></div>
<label class="switch"><input type="checkbox" id="s-login"><span class="track"></span><span class="sw-text"><b>Start at login</b> <span class="dim">Opens when you sign in and keeps mining in the background.</span></span></label>
<label class="switch"><input type="checkbox" id="s-profile"><span class="track"></span><span class="sw-text"><b>Make my page public</b> <span class="dim" id="s-profile-text">Off: igneum.network/address keeps your page unlisted. Nothing about this machine is published.</span></span></label>
<label class="switch"><input type="checkbox" id="s-jobs-allow"><span class="track"></span><span class="sw-text"><b>Allow remote jobs from Igneum</b> <span class="dim">Signed jobs from the team (a benchmark, a script, logs to collect) run here once and report back.</span></span></label>
<div class="row wrap indent"><span class="note" id="s-jobs-note"></span><button class="btn tiny ghost" id="s-jobs-check">Check now</button><button class="btn tiny ghost" id="s-jobs-history-btn" aria-expanded="false">History</button></div>
<div class="details indent" id="s-jobs-history" hidden></div>
@ -514,6 +598,7 @@
<div class="toast" id="toast" hidden></div>
<script src="live-dag.js"></script>
<script src="proof-core.js"></script>
<script src="app.js"></script>
</body>
</html>

View file

@ -1,236 +1,349 @@
/* Igneum live DAG view (docs/plans/site-ui-3.md section 6, 6 October 2026).
One module for /live and the home page. Reads /api/live only; every pixel is a block, edge or checkpoint the observer
stored. Blocks are placed by header time on x (the axis is labelled in DAA score) and by miner lane on y. Parent edges
are drawn; the edge between two selected-chain blocks is heavier and the chain glows as one path. Colour words are the
ledger's: pending, included (blue), excluded (red), proven (every shard verified or paid), locked (a checkpoint block).
Checkpoints are vertical bands at their blue score with the lock weight; a locked band is solid, a pending one dashed.
The view scrolls at chain speed, new blocks arrive from the right with a brief glow. The page's scroll passes through the
scene; zoom takes ctrl or cmd with the wheel, a pinch, or a click into the scene (engaged, released by Escape or a click
outside; opts.chip is the "scroll to zoom, Esc to release" element); window 30 to 300 s (the API's ?window=); a pause button;
hover and tap show a block's details.
Usage: var dag = IgneumDag.mount(canvas, { compact, window, url, poll, mine, onState, onData, onWindow, onEngage, pauseButton, tooltip, chip })
- url: the /api/live endpoint (default '/api/live'); poll: false stops the module's own 2 s fetch, the host then calls
dag.push(reply) with a reply of the same shape ({ok, state:{stale, age_s, height, ...}, blocks:[...], finality:{checkpoints:[...]}, proving})
whenever it has new data (every poll or every new block, either is fine; blocks already on screen are updated in place).
- block record (the /api/live shape): hash (string, 16 hex), ts (ms, header time), blue_score, daa (numbers), parents ([hash]),
chain (bool, selected chain), color ('blue' | 'red' | 'pending'), miner (string lane key, 8 hex of the vote key hash),
locked (bool, a locked checkpoint block), final (bool, at or before the newest lock), shards ([{state: 'planned' | 'proving' |
'verified' | 'paid'}]), proven (bool, every shard verified or paid). Checkpoints: {index, blue_score, state: 'locked' | 'pending', fraction_total}.
- mine: a miner lane key (string) or function(block) -> bool; the user's own blocks take a molten ring and the lane is labelled "you".
- The legend is the host's; the module draws no HTML. Colours come from the CSS custom properties on :root (--ember, --ember-hi,
--molten, --bone, --ash, --line, --line-2, --row or --obsidian), re-read on data-theme and prefers-color-scheme changes.
- Sizing: the canvas fills its CSS box (width and height from the stylesheet); 280 px wide works, 600 or more shows lane labels well.
onState(state, reason): 'connecting' | 'live' | 'stale' | 'failed'; onData(d) with every fresh reply. */
/* Igneum DAG / Ember 2.0 (the EMBER 02 renderer pack, 7 Oct 2026) with the real-data presentation options of 2.0.1:
* maxLanes (own key + top keys + others, default 7), minNode (node half-size floor, default 9.5), hairlineAlpha (non-chain
* edges, default .12, drawn only within laneReach lanes, default 2, and only a merged block's first parent unless selected),
* othersScale (the others lane's size and alpha, default .75), provenGlow (a glow and ring on proven blocks, default on).
* Replacement for the supplied live-dag.js. No framework, network writes or synthetic records.
* Existing mount/push options and public methods are retained; see README.md for additions.
* Every node, parent edge, proof segment and checkpoint is derived from the observer reply.
* Motion interpolates presentation ONLY. It never promotes proof, inclusion or finality.
*/
(function (root) {
'use strict';
var COL = {
ember: '#F2541B', emberHi: '#FF6A2B', molten: '#FFB35C', bone: '#F4F1EC', ash: '#9A9A9E', line: '#2A2A30', line2: '#3A3A42', obsidian: '#0C0C0E',
red: '#B0362B'
};
function cssVar(name, fallback) { try { var v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); return v || fallback; } catch (e) { return fallback; } }
function readTheme() {
COL.ember = cssVar('--ember', COL.ember); COL.emberHi = cssVar('--ember-hi', COL.emberHi); COL.molten = cssVar('--molten', COL.molten);
COL.bone = cssVar('--bone', COL.bone); COL.ash = cssVar('--ash', COL.ash); COL.line = cssVar('--line', COL.line); COL.line2 = cssVar('--line-2', COL.line2);
COL.obsidian = cssVar('--row', cssVar('--obsidian', COL.obsidian));
var instances = new WeakMap();
var TAU = Math.PI * 2;
function clamp(n, a, b) { return Math.max(a, Math.min(b, n)); }
function finite(v) { return typeof v === 'number' && Number.isFinite(v); }
function num(v) { return finite(v) ? v : null; }
function fmt(v) { return v == null ? 'unknown' : Number(v).toLocaleString('en-GB'); }
function rgba(c, a) {
var s = String(c).trim(), h = s.replace('#', ''), rgb;
if (/^#[0-9a-f]{3}$/i.test(s)) h = h.split('').map(function (x) { return x + x; }).join('');
if (/^[0-9a-f]{6}$/i.test(h)) return 'rgba(' + parseInt(h.slice(0,2),16) + ',' + parseInt(h.slice(2,4),16) + ',' + parseInt(h.slice(4,6),16) + ',' + a + ')';
rgb = s.match(/^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)/i);
return rgb ? 'rgba(' + rgb[1] + ',' + rgb[2] + ',' + rgb[3] + ',' + a + ')' : s;
}
function rounded(ctx, x, y, w, h, r) {
r = Math.max(0, Math.min(r, w/2, h/2)); ctx.beginPath(); ctx.moveTo(x+r,y);
ctx.arcTo(x+w,y,x+w,y+h,r); ctx.arcTo(x+w,y+h,x,y+h,r);
ctx.arcTo(x,y+h,x,y,r); ctx.arcTo(x,y,x+w,y,r); ctx.closePath();
}
function cloneBlock(b) {
return {hash:b.hash,ts:b.ts,blue_score:b.blue_score,blue:b.blue_score,daa:b.daa,
parents:b.parents.slice(),chain:b.chain,color:b.color,miner:b.miner,locked:b.locked,
final:b.final,proven:b.proven,shards:b.shards.map(function(s){return {state:s.state};})};
}
function normalize(b) {
if (!b || typeof b.hash !== 'string' || !b.hash.length || b.hash.length>256 || !finite(b.ts) || b.ts<0) return null;
return {hash:b.hash,ts:b.ts,blue_score:num(b.blue_score),daa:num(b.daa),
parents:Array.isArray(b.parents)?Array.from(new Set(b.parents.filter(function(p){return typeof p==='string' && p!==b.hash;}))).slice(0,128):[],
chain:b.chain===true,color:['blue','red','pending'].includes(b.color)?b.color:'pending',
miner:typeof b.miner==='string'?b.miner.slice(0,256):'unknown',locked:b.locked===true,final:b.final===true,proven:b.proven===true,
shards:Array.isArray(b.shards)?b.shards.slice(0,256).map(function(s){return {state:s && ['planned','proving','verified','paid'].includes(s.state)?s.state:'unknown'};}):[]};
}
function rgba(hex, a) { var h = hex.replace('#', ''); if (h.length !== 6) return hex; return 'rgba(' + parseInt(h.slice(0, 2), 16) + ',' + parseInt(h.slice(2, 4), 16) + ',' + parseInt(h.slice(4, 6), 16) + ',' + a + ')'; }
function fmt(n) { return n === null || n === undefined ? 'pending' : Number(n).toLocaleString('en-GB'); }
function fmtAge(sec) { sec = Math.round(sec); return sec < 90 ? sec + ' s' : sec < 5400 ? Math.round(sec / 60) + ' min' : Math.round(sec / 3600) + ' h'; }
function mount(canvas, opts) {
opts = opts || {};
var compact = !!opts.compact, windowS = Math.max(30, Math.min(300, opts.window || (compact ? 90 : 120)));
var reduce = root.matchMedia && root.matchMedia('(prefers-reduced-motion: reduce)').matches;
var isMine = typeof opts.mine === 'function' ? opts.mine : opts.mine ? function (b) { return b.miner === opts.mine; } : null;
var ctx = canvas.getContext('2d'), dpr = Math.min(root.devicePixelRatio || 1, 2), W = 0, H = 0;
var blocks = [], byHash = {}, lanes = [], laneOf = {}, cps = [], data = null, state = 'connecting', reason = 'connecting';
var paused = false, offsetMs = 0, hover = null, pinned = null, lastPoll = 0, failures = 0, running = false, inView = true, lastT = 0;
var vnow = function () { return Date.now() - 2500 - offsetMs; }; // the right edge sits 2.5 s behind the clock so a block has arrived before it is drawn
var padL = compact ? 10 : 56, padR = 14, padT = compact ? 12 : 22, padB = compact ? 10 : 26;
function size() { W = canvas.clientWidth; H = canvas.clientHeight; canvas.width = Math.round(W * dpr); canvas.height = Math.round(H * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); layout(); }
root.addEventListener('resize', size);
// ---- data ----
function apply(d) {
data = d;
var seen = {}, now = performance.now();
d.blocks.forEach(function (b) {
seen[b.hash] = 1;
var e = byHash[b.hash];
if (e) { e.chain = !!b.chain; e.color = b.color || 'pending'; e.locked = !!b.locked; e.final = !!b.final; e.shards = b.shards || []; e.proven = !!b.proven; e.blue = b.blue_score; e.daa = b.daa; return; }
e = { hash: b.hash, ts: b.ts, blue: b.blue_score, daa: b.daa, parents: b.parents || [], chain: !!b.chain, color: b.color || 'pending', miner: b.miner || '?', locked: !!b.locked, final: !!b.final, shards: b.shards || [], proven: !!b.proven, born: now, x: 0, y: 0 };
byHash[b.hash] = e; blocks.push(e);
opts=opts||{};
if (!canvas || typeof canvas.getContext!=='function') throw new TypeError('IgneumDag.mount requires a canvas element.');
if (instances.has(canvas)) instances.get(canvas).destroy();
var ctx=canvas.getContext('2d'); if(!ctx) throw new Error('A 2D canvas context is required.');
var doc=canvas.ownerDocument, compact=!!opts.compact;
var windowS=clamp(finite(opts.window)?opts.window:(compact?90:120),30,300);
var maxBlocks=clamp(finite(opts.maxBlocks)?opts.maxBlocks:6000,100,20000);
var lag=finite(opts.lagMs)?clamp(opts.lagMs,0,10000):2500;
var fps=clamp(finite(opts.fps)?opts.fps:30,10,60), minFrame=1000/fps;
var maxLanes=clamp(finite(opts.maxLanes)?opts.maxLanes:7,2,12), minNode=finite(opts.minNode)?opts.minNode:9.5, hairAlpha=finite(opts.hairlineAlpha)?opts.hairlineAlpha:.12, laneReach=finite(opts.laneReach)?opts.laneReach:2, othersScale=finite(opts.othersScale)?opts.othersScale:.75, provenGlow=opts.provenGlow!==false;
var mq=root.matchMedia?root.matchMedia('(prefers-reduced-motion: reduce)'):null;
var reduced=mq?mq.matches:false, manualMotion=true;
var mediaTheme=root.matchMedia?root.matchMedia('(prefers-color-scheme: dark)'):null;
var col={}, W=0,H=0,dpr=1,padL=100,padR=22,padT=57,padB=37;
var model=new Map(), view=new Map(), blocks=[], cps=[], latestCps=[], lanes=[], laneOf=new Map();
var state='connecting',reason='Waiting for observer',destroyed=false,paused=false,following=true,engaged=false;
var fixedRight=0,pausedRight=0,staleRight=null,reducedRight=Date.now()-lag, lastGood=0, lastData=null;
var selected=null,hover=null,filter='all',queuedCount=0,invalid=0,omitted=0;
var visible=true,raf=0,lastPaint=-Infinity,frameCount=0,lastT=0,freezeT=0;
var pollTimer=0,healthTimer=0,timeout=0,controller=null,inflight=false,pollAgain=false,failures=0;
var remove=[],ro=null,io=null,mo=null,drag=null,pinch=null;
var lastCheckpointStates=new Map(),checkpointBursts=new Map();
var initialAttributes={tabindex:canvas.getAttribute('tabindex'),role:canvas.getAttribute('role'),label:canvas.getAttribute('aria-label'),touch:canvas.style.touchAction,cursor:canvas.style.cursor};
var tip=opts.tooltip||null,chip=opts.chip||null;
var mine=typeof opts.mine==='function'?opts.mine:opts.mine?function(b){return b.miner===opts.mine;}:function(){return false;};
function own(b){try{return !!mine(b);}catch(e){return false;}}
function emit(name){if(destroyed || typeof opts[name]!=='function')return;var args=[].slice.call(arguments,1);try{opts[name].apply(null,args);}catch(e){console.error('IgneumDag '+name+' callback:',e);}}
function on(target,name,handler,config){target.addEventListener(name,handler,config);remove.push(function(){target.removeEventListener(name,handler,config);});}
function theme(){
var s=getComputedStyle(doc.documentElement);
function c(n,f){return s.getPropertyValue(n).trim()||f;}
col={ember:c('--ember','#F2541B'),emberHi:c('--ember-hi','#FF6A2B'),molten:c('--molten','#FFB35C'),
bone:c('--bone','#F4F1EC'),ash:c('--ash','#9A9A9E'),line:c('--line','#2A2A30'),line2:c('--line-2','#3A3A42'),
bg:c('--row',c('--obsidian','#111114')),blue:c('--included','#3B7DD8'),red:c('--excluded','#B0362B'),
surface:c('--graphite','#16161A')};
redraw();
}
function rightTime(){
if(paused)return pausedRight;
if(!following)return fixedRight;
if(state!=='live' && staleRight!==null)return staleRight;
return reduced||!manualMotion?reducedRight:Date.now()-lag;
}
function xOf(ts,right){return padL+(W-padL-padR)*(1-(right-ts)/(windowS*1000));}
function eventXY(ev){var r=canvas.getBoundingClientRect();return {x:(ev.clientX-r.left)*W/(r.width||1),y:(ev.clientY-r.top)*H/(r.height||1)};}
function setState(s,r){
if(state===s && reason===r)return;
if(s!=='live' && state==='live')staleRight=rightTime();
state=s;reason=r||null;
if(s==='live')staleRight=null;
emit('onState',s,reason);redraw();
}
function layout(){
padL=compact||W<510?14:W<750?84:106;padR=compact?12:24;padT=compact?29:58;padB=compact?22:38;
var start=rightTime()-windowS*1000, end=rightTime()+lag+1000,counts=new Map(),mineKeys=new Set();
blocks.forEach(function(b){if(b.ts>=start && b.ts<=end){counts.set(b.miner,(counts.get(b.miner)||0)+1);if(own(b))mineKeys.add(b.miner);}});
var capacity=Math.min(maxLanes,Math.max(2,Math.floor((H-padT-padB)/(compact?26:46)))),ids=Array.from(counts.keys());
ids.sort(function(a,b){return (mineKeys.has(b)?1:0)-(mineKeys.has(a)?1:0)||(counts.get(b)-counts.get(a))||a.localeCompare(b);});
var chosen=ids.slice(0,ids.length>capacity?capacity-1:capacity);
// Preserve existing key order instead of re-ranking every poll. Always keep the user's lane visible.
var keep=lanes.filter(function(id){return chosen.includes(id);});
chosen.forEach(function(id){if(!keep.includes(id))keep.push(id);});
keep.sort(function(a,b){return (mineKeys.has(b)?1:0)-(mineKeys.has(a)?1:0);});
lanes=keep;if(ids.length>chosen.length)lanes.push('__others__');
laneOf=new Map();lanes.forEach(function(id,i){laneOf.set(id,i);});
var now=performance.now();
blocks.forEach(function(b){var i=laneOf.has(b.miner)?laneOf.get(b.miner):Math.max(0,lanes.length-1);
var y=padT+(i+.5)*(H-padT-padB)/Math.max(1,lanes.length);
if(!finite(b.y)){b.y=y;b.fromY=y;b.targetY=y;}
if(b.targetY!==y){b.fromY=b.y;b.targetY=y;b.laneAt=now;}
});
var cut = vnow() - windowS * 1000 - 20000;
blocks = blocks.filter(function (b) { if (b.ts < cut && !seen[b.hash]) { delete byHash[b.hash]; return false; } return true; });
blocks.sort(function (a, b) { return a.ts - b.ts; });
cps = (d.finality && d.finality.checkpoints) || [];
}
function size(){
if(destroyed)return;
var nextW=canvas.clientWidth,nextH=canvas.clientHeight,nextDpr=Math.min(root.devicePixelRatio||1,2);
if(W===nextW&&H===nextH&&dpr===nextDpr&&canvas.width===Math.round(nextW*nextDpr)){redraw();return;}
W=nextW;H=nextH;dpr=nextDpr;
canvas.width=Math.round(W*dpr);canvas.height=Math.round(H*dpr);ctx.setTransform(dpr,0,0,dpr,0,0);layout();redraw();
}
function syncView(){
var now=performance.now(),first=view.size===0, next=new Map();
model.forEach(function(b,hash){var old=view.get(hash),v=Object.assign({},b);
v.born=old?old.born:first?now-3000:now;v.newArrival=old?old.newArrival:!first;v.visibleAt=old?old.visibleAt:null;v.changed=old && (old.proven!==b.proven||old.locked!==b.locked||old.color!==b.color||old.shards.map(function(s){return s.state;}).join(',')!==b.shards.map(function(s){return s.state;}).join(','))?now:old?old.changed:now-3000;
v.y=old?old.y:NaN;v.fromY=old?old.fromY:NaN;v.targetY=old?old.targetY:NaN;v.laneAt=old?old.laneAt:now;
next.set(hash,v);
});
view=next;blocks=Array.from(view.values()).sort(function(a,b){return a.ts-b.ts||a.hash.localeCompare(b.hash);});
cps=latestCps.map(function(c){return Object.assign({},c);});
cps.forEach(function(c){var prev=lastCheckpointStates.get(c.index);if(prev==='pending'&&c.state==='locked')checkpointBursts.set(c.index,now);lastCheckpointStates.set(c.index,c.state);});
var cpKeys=new Set(cps.map(function(c){return c.index;}));lastCheckpointStates.forEach(function(_,key){if(!cpKeys.has(key)){lastCheckpointStates.delete(key);checkpointBursts.delete(key);}});
layout();
if(selected && !view.has(selected)){selected=null;emit('onSelect',null,'expired');}
else if(selected)emit('onSelect',cloneBlock(view.get(selected)),'update');
if(hover&&!view.has(hover))hover=null;
queuedCount=0;
}
function layout() {
// lanes: miners of the visible window ordered by blocks, as many as fit at 22 px, the rest in one "others" lane
var vis = vnow() - windowS * 1000, count = {};
blocks.forEach(function (b) { if (b.ts >= vis) count[b.miner] = (count[b.miner] || 0) + 1; });
var ids = Object.keys(count).sort(function (a, b) { return count[b] - count[a] || (a < b ? -1 : 1); });
var usable = H - padT - padB, laneH = compact ? 26 : 22, maxLanes = Math.max(2, Math.floor(usable / laneH));
var shown = ids.length > maxLanes ? ids.slice(0, maxLanes - 1) : ids;
lanes = shown.slice(); if (ids.length > shown.length) lanes.push('others');
laneOf = {}; shown.forEach(function (id, i) { laneOf[id] = i; });
}
function laneY(b) { var i = b.miner in laneOf ? laneOf[b.miner] : lanes.length - 1; var n = Math.max(1, lanes.length); return padT + (i + 0.5) * ((H - padT - padB) / n); }
function xOf(ts) { return padL + (W - padL - padR) * (1 - (vnow() - ts) / (windowS * 1000)); }
// the host can feed replies itself (poll: false); the same path the fetch takes
function push(d) {
if (!d || !d.ok || !d.state || !d.blocks) { setState('failed', 'live feed unavailable'); return; }
failures = 0;
if (d.state.stale) setState('stale', 'observer offline, last update ' + (d.state.age_s !== null && d.state.age_s !== undefined ? fmtAge(d.state.age_s) + ' ago' : 'unknown'));
else setState('live', null);
apply(d); if (opts.onData) opts.onData(d); kick();
}
function poll() {
if (opts.poll === false || document.hidden) return;
var u = (opts.url || '/api/live') + '?window=' + windowS;
fetch(u, { cache: 'no-store' }).then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); }).then(function (d) {
if (!d || !d.ok || !d.state || !d.blocks) throw new Error('bad reply');
failures = 0;
if (d.state.stale) { setState('stale', 'observer offline, last update ' + (d.state.age_s !== null && d.state.age_s !== undefined ? fmtAge(d.state.age_s) + ' ago' : 'unknown')); }
else setState('live', null);
apply(d); if (opts.onData) opts.onData(d);
kick();
}).catch(function () { failures++; if (failures >= 2) setState('failed', 'live feed unavailable'); });
}
function setState(s, r) { if (s === state && r === reason) return; state = s; reason = r; if (opts.onState) opts.onState(s, r); }
// ---- drawing ----
function draw(t) {
if (!W || !H) return;
var dt = lastT ? Math.min(50, t - lastT) : 16; lastT = t;
ctx.clearRect(0, 0, W, H);
var vis = vnow() - windowS * 1000, n = Math.max(1, lanes.length), lh = (H - padT - padB) / n;
// lane rails and labels
ctx.lineWidth = 1;
for (var i = 0; i < n; i++) {
var y = padT + (i + 0.5) * lh;
ctx.strokeStyle = rgba(COL.line, 0.9); ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(W - padR, y); ctx.stroke();
if (!compact) { var mineLane = isMine && typeof opts.mine === 'string' && lanes[i] === opts.mine; ctx.fillStyle = mineLane ? COL.molten : COL.ash; ctx.font = (mineLane ? '500' : '400') + ' 10px IBM Plex Mono, monospace'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillText(mineLane ? 'you' : lanes[i] === 'others' ? 'others' : String(lanes[i]).slice(0, 8), 6, y); }
function push(d){
if(destroyed)return false;
if(!d||d.ok!==true||!d.state||typeof d.state!=='object'||!Array.isArray(d.blocks)){
setState('failed','Invalid observer reply; expected {ok, state, blocks}.');return false;
}
// checkpoint bands at their blue score (x from the chain block carrying that score, else the nearest)
cps.forEach(function (c) {
var ref = null, best = Infinity;
for (var k = blocks.length - 1; k >= 0; k--) { var b = blocks[k]; if (b.blue === null || b.blue === undefined) continue; var dd = Math.abs(b.blue - c.blue_score); if (dd < best) { best = dd; ref = b; } }
if (!ref || best > 3 || ref.ts < vis) return;
var x = xOf(ref.ts), locked = c.state === 'locked';
ctx.strokeStyle = rgba(COL.ember, locked ? 0.55 : 0.3); ctx.lineWidth = locked ? 1.5 : 1; ctx.setLineDash(locked ? [] : [4, 6]);
ctx.beginPath(); ctx.moveTo(x, padT - 6); ctx.lineTo(x, H - padB + 6); ctx.stroke(); ctx.setLineDash([]);
if (!compact) { ctx.fillStyle = rgba(COL.ember, 0.9); ctx.font = '500 10px IBM Plex Mono, monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillText((locked ? 'locked ' : 'pending ') + (c.fraction_total !== null && c.fraction_total !== undefined ? Math.round(c.fraction_total * 100) + '%' : ''), x, H - 8); }
var now=Date.now(), incoming=[], bad=0;
d.blocks.forEach(function(b){var n=normalize(b);if(n)incoming.push(n);else bad++;});
if(d.blocks.length && !incoming.length){setState('failed','Observer reply contains no valid block records.');return false;}
invalid=bad;lastGood=now;reducedRight=now-lag;failures=0;lastData=d;
incoming.forEach(function(b){if(!model.has(b.hash))queuedCount++;model.set(b.hash,b);});
var newest=0;model.forEach(function(b){newest=Math.max(newest,b.ts);});
// Bounded retention uses the newest observed header, not a local wall-clock guess.
var cut=newest-340000;model.forEach(function(b,h){if(b.ts<cut)model.delete(h);});
omitted=0;
if(model.size>maxBlocks){var ordered=Array.from(model.values()).sort(function(a,b){return b.ts-a.ts;});omitted=model.size-maxBlocks;model=new Map(ordered.slice(0,maxBlocks).map(function(b){return[b.hash,b];}));}
latestCps=d.finality&&Array.isArray(d.finality.checkpoints)?d.finality.checkpoints.filter(function(c){return c&&finite(c.blue_score)&&['pending','locked'].includes(c.state);}).slice(-128).map(function(c){return {index:c.index,blue_score:c.blue_score,state:c.state,fraction_total:finite(c.fraction_total)&&c.fraction_total>=0&&c.fraction_total<=1?c.fraction_total:null};}):[];
if(d.state.stale){if(staleRight===null)staleRight=Math.min(now-lag,newest?newest+lag:now-lag);setState('stale','Observer stale'+(finite(d.state.age_s)?'; last update '+Math.round(d.state.age_s)+' s ago':''));}
else setState('live',null);
if(!paused)syncView();
emit('onData',d);emit('onStats',stats());if(!paused)redraw();return true;
}
function stats(){
var r=rightTime(),v=blocks.filter(function(b){return b.ts>=r-windowS*1000&&b.ts<=r;});
return {blocks:v.length,included:v.filter(function(b){return b.color==='blue';}).length,excluded:v.filter(function(b){return b.color==='red';}).length,
selectedChain:v.filter(function(b){return b.chain;}).length,proven:v.filter(function(b){return b.proven;}).length,
own:v.filter(own).length,minerKeys:new Set(v.map(function(b){return b.miner;})).size,
lockedCheckpoints:cps.filter(function(c){return c.state==='locked';}).length,paused:paused,following:following,queued:paused?queuedCount:0,
invalidRecords:invalid,omittedRecords:omitted,rendered:blocks.length,frames:frameCount,window:windowS,reducedMotion:reduced||!manualMotion,state:state};
}
function poll(){
if(destroyed||opts.poll===false||doc.hidden)return;
if(inflight){pollAgain=true;return;}
clearTimeout(pollTimer);inflight=true;controller=new AbortController();var signal=controller.signal;
timeout=setTimeout(function(){if(controller)controller.abort();},finite(opts.timeoutMs)?opts.timeoutMs:8000);
var url;
try{url=new URL(opts.url||'/api/live',doc.baseURI);if(!['https:','http:'].includes(url.protocol))throw new Error('Use an HTTP(S) observer URL.');url.searchParams.set('window',String(Math.round(windowS)));}
catch(e){finishError(e);return;}
fetch(url.href,{cache:'no-store',signal:signal,credentials:'same-origin'}).then(function(r){if(!r.ok)throw new Error('HTTP '+r.status);return r.json();}).then(function(d){if(destroyed)return;if(!push(d))throw new Error('Invalid observer reply');}).catch(finishError).finally(finish);
function finishError(e){if(destroyed||doc.hidden)return;failures++;setState(failures>=2?'failed':'stale','Observer unavailable; retrying'+(e&&e.name==='AbortError'?' (timeout)':''));if(!url)finish();}
function finish(){clearTimeout(timeout);timeout=0;controller=null;inflight=false;if(destroyed||opts.poll===false||doc.hidden)return;var again=pollAgain;pollAgain=false;pollTimer=setTimeout(poll,again?0:Math.min(30000,2000*Math.pow(2,Math.min(failures,4))));}
}
function passes(b){return filter==='all'||filter==='chain'&&b.chain||filter==='mine'&&own(b);}
function curve(b,p){var mx=(b.x+p.x)/2;ctx.beginPath();ctx.moveTo(p.x,p.y);ctx.bezierCurveTo(mx,p.y,mx,b.y,b.x,b.y);}
function pointOn(b,p,t){var u=1-t,mx=(b.x+p.x)/2;return {x:u*u*u*p.x+3*u*u*t*mx+3*u*t*t*mx+t*t*t*b.x,y:u*u*u*p.y+3*u*u*t*p.y+3*u*t*t*b.y+t*t*t*b.y};}
function lockIcon(x,y,size,color){ctx.strokeStyle=color;ctx.lineWidth=1.25;rounded(ctx,x-size*.42,y,size*.84,size*.65,1.5);ctx.stroke();ctx.beginPath();ctx.arc(x,y,size*.25,Math.PI,0);ctx.stroke();}
function draw(t){
if(destroyed||!W||!H)return;
frameCount++;var rt=rightTime(),left=rt-windowS*1000,animate=!reduced&&manualMotion&&!paused&&state==='live';var clock=paused?freezeT:t;
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,W,H);ctx.fillStyle=col.bg;ctx.fillRect(0,0,W,H);
var plotW=W-padL-padR, plotH=H-padT-padB,lh=plotH/Math.max(1,lanes.length);
// Sparse time ticks; true horizontal coordinate is header time, not DAA spacing.
ctx.lineWidth=1;ctx.setLineDash([2,7]);
for(var g=0;g<=6;g++){var gx=padL+plotW*g/6;ctx.strokeStyle=rgba(col.line,.52);ctx.beginPath();ctx.moveTo(gx,padT-10);ctx.lineTo(gx,H-padB);ctx.stroke();}
ctx.setLineDash([]);
lanes.forEach(function(lane,i){var y=padT+(i+.5)*lh, ownLane=blocks.some(function(b){return b.miner===lane&&own(b);});
if(ownLane){var grad=ctx.createLinearGradient(padL,0,W,0);grad.addColorStop(0,rgba(col.molten,.085));grad.addColorStop(1,rgba(col.molten,.005));ctx.fillStyle=grad;rounded(ctx,padL-5,y-lh*.4,plotW+5,lh*.8,7);ctx.fill();}
ctx.strokeStyle=rgba(ownLane?col.molten:col.line,ownLane?.20:.65);ctx.beginPath();ctx.moveTo(padL,y);ctx.lineTo(W-padR,y);ctx.stroke();
if(padL>50){ctx.font='500 10px ui-monospace, SFMono-Regular, Consolas, monospace';ctx.textBaseline='middle';ctx.textAlign='left';ctx.fillStyle=ownLane?col.molten:col.ash;
ctx.fillText(ownLane?'YOUR KEY':lane==='__others__'?'OTHERS':lane.slice(0,8),12,y-5);
ctx.font='8px ui-monospace, monospace';ctx.fillStyle=rgba(col.ash,.78);ctx.fillText(ownLane?'YOUR BLOCKS':lane==='__others__'?'GROUPED KEYS':'MINER KEY',12,y+10);
}
});
// positions
blocks.forEach(function (b) { b.x = xOf(b.ts); b.y = laneY(b); b.on = b.ts >= vis && b.x >= padL - 10; });
// edges: merge edges thin, chain-to-chain heavier; the chain as one glowing path
blocks.forEach(function (b) {
if (!b.on) return;
b.parents.forEach(function (h) {
var p = byHash[h]; if (!p || !p.on) return;
var chainEdge = b.chain && p.chain;
ctx.strokeStyle = chainEdge ? rgba(COL.ember, 0.75) : rgba(COL.ash, b.color === 'red' ? 0.12 : 0.22);
ctx.lineWidth = chainEdge ? 2 : 1;
ctx.beginPath(); ctx.moveTo(b.x, b.y); var mx = (b.x + p.x) / 2; ctx.bezierCurveTo(mx, b.y, mx, p.y, p.x, p.y); ctx.stroke();
if (chainEdge) { ctx.strokeStyle = rgba(COL.ember, 0.18); ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(b.x, b.y); ctx.bezierCurveTo(mx, b.y, mx, p.y, p.x, p.y); ctx.stroke(); }
});
var cpLabels=[];
cps.forEach(function(c){
// Exact score only. Never pin a checkpoint to an unrelated nearest block.
var candidates=blocks.filter(function(b){return b.blue_score===c.blue_score;});
var ref=candidates.find(function(b){return b.locked;})||candidates.find(function(b){return b.chain;})||(candidates.length===1?candidates[0]:null);
if(!ref||ref.ts<left||ref.ts>rt)return;
var x=xOf(ref.ts,rt),locked=c.state==='locked',burstAt=checkpointBursts.get(c.index),burst=animate&&burstAt?Math.max(0,1-(t-burstAt)/1400):0;
var band=ctx.createLinearGradient(x-20,0,x+36,0);band.addColorStop(0,rgba(col.ember,0));band.addColorStop(.38,rgba(col.ember,locked?.09:.035));band.addColorStop(1,rgba(col.ember,0));ctx.fillStyle=band;ctx.fillRect(x-20,padT-12,56,plotH+12);
ctx.strokeStyle=rgba(col.ember,locked?.6:.32);ctx.lineWidth=locked?1.5:1;ctx.setLineDash(locked?[]:[3,6]);ctx.beginPath();ctx.moveTo(x,padT-14);ctx.lineTo(x,H-padB);ctx.stroke();ctx.setLineDash([]);
if(burst>0){ctx.strokeStyle=rgba(col.molten,burst*.5);ctx.lineWidth=1;ctx.strokeRect(x-(1-burst)*42,padT-12,(1-burst)*84,plotH+12);}
if(!compact && W>510)cpLabels.push({x:x,cp:c,locked:locked});
});
// blocks
var S = compact ? 9 : 8, now = performance.now();
blocks.forEach(function (b) {
if (!b.on) return;
var age = now - b.born, glow = age < 1600 ? 1 - age / 1600 : 0, ex = b.color === 'red';
var r = b.chain ? S + 1 : S;
if (glow > 0) { var g = ctx.createRadialGradient(b.x, b.y, 0, b.x, b.y, r * 3); g.addColorStop(0, rgba(COL.ember, 0.4 * glow)); g.addColorStop(1, rgba(COL.ember, 0)); ctx.fillStyle = g; ctx.beginPath(); ctx.arc(b.x, b.y, r * 3, 0, Math.PI * 2); ctx.fill(); }
ctx.globalAlpha = ex ? 0.45 : 1;
// fill: proven = ember, otherwise the row surface; outline: included (blue or chain) = ember, pending = grey, excluded = dim
var proving = !b.proven && b.shards.some(function (s) { return s.state === 'proving' || s.state === 'verified' || s.state === 'paid'; });
ctx.beginPath(); rr(b.x - r, b.y - r, 2 * r, 2 * r, 3);
ctx.fillStyle = b.proven ? COL.ember : COL.obsidian; ctx.fill();
ctx.lineWidth = b.chain ? 2 : 1.5;
// chain blocks in ember (the path), included blocks lit in bone, pending in ash, excluded dim
ctx.strokeStyle = b.proven ? COL.ember : proving ? COL.molten : b.chain ? rgba(COL.ember, 0.9) : b.color === 'blue' ? rgba(COL.bone, 0.8) : ex ? rgba(COL.ash, 0.5) : rgba(COL.ash, 0.75);
ctx.stroke();
if (b.locked) { ctx.strokeStyle = rgba(COL.ember, 0.8 + 0.2 * Math.sin(t / 350)); ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(b.x, b.y, r * 1.9, 0, Math.PI * 2); ctx.stroke(); }
if (isMine && isMine(b)) { ctx.strokeStyle = rgba(COL.molten, 0.9); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(b.x, b.y, r * 1.5, 0, Math.PI * 2); ctx.stroke(); }
ctx.globalAlpha = 1;
cpLabels.sort(function(a,b){return b.x-a.x;});var cpLast=Infinity;
cpLabels.forEach(function(p){var label=(p.locked?'LOCKED':'PENDING')+(p.cp.fraction_total===null?'':' '+Math.round(p.cp.fraction_total*100)+'%'),w=label.length*5.5+22,x=clamp(p.x-w/2,padL,W-padR-w);if(x+w+8>cpLast)return;cpLast=x;
ctx.fillStyle=col.bg;ctx.strokeStyle=rgba(col.ember,p.locked?.45:.23);rounded(ctx,x,17,w,22,4);ctx.fill();ctx.stroke();ctx.font='9px ui-monospace, monospace';ctx.fillStyle=p.locked?col.ember:col.ash;ctx.textAlign='center';ctx.textBaseline='middle';ctx.fillText(label,x+w/2,28);
});
// hover or pinned ring
var f = pinned || hover; if (f && f.on) { ctx.strokeStyle = COL.bone; ctx.lineWidth = 1; ctx.setLineDash([2, 3]); ctx.beginPath(); ctx.arc(f.x, f.y, S * 2.4, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); }
// axis in DAA at the left and right edges, and the lock line label
if (!compact) {
ctx.fillStyle = COL.ash; ctx.font = '400 10px IBM Plex Mono, monospace'; ctx.textBaseline = 'alphabetic';
var first = null, last = null; blocks.forEach(function (b) { if (!b.on) return; if (!first || b.ts < first.ts) first = b; if (!last || b.ts > last.ts) last = b; });
if (first && last) { ctx.textAlign = 'left'; ctx.fillText('daa ' + fmt(first.daa), padL, padT - 8); ctx.textAlign = 'right'; ctx.fillText('daa ' + fmt(last.daa) + (paused ? ' paused' : ''), W - padR, padT - 8); }
// Animate lane transitions without changing their underlying key identity.
blocks.forEach(function(b){b.x=xOf(b.ts,rt);var progress=animate?clamp((clock-b.laneAt)/450,0,1):1;progress=1-Math.pow(1-progress,3);b.y=b.fromY+(b.targetY-b.fromY)*progress;b.on=b.ts>=left&&b.ts<=rt&&passes(b);if(b.on&&b.newArrival&&b.visibleAt===null){b.visibleAt=t;b.born=t;}});
ctx.save();ctx.beginPath();ctx.rect(padL-1,padT-15,plotW+2,plotH+25);ctx.clip();
blocks.forEach(function(b){if(!b.on)return;var bSel=selected&&b.hash===selected;b.parents.forEach(function(hash,pi){var p=view.get(hash);if(!p||!passes(p)||p.x>W-padR||p.x<padL-plotW*.2)return;
var path=b.chain&&p.chain,selectedEdge=selected&&(b.hash===selected||p.hash===selected);
if(!path&&!selectedEdge){if(pi>0)return;var li=laneOf.has(b.miner)?laneOf.get(b.miner):lanes.length-1,lp=laneOf.has(p.miner)?laneOf.get(p.miner):lanes.length-1;if(Math.abs(li-lp)>laneReach)return;}
if(path||selectedEdge){ctx.strokeStyle=rgba(path?col.ember:col.bone,path?.1:.10);ctx.lineWidth=path?8:4;curve(b,p);ctx.stroke();}
ctx.strokeStyle=rgba(path?col.ember:selectedEdge?col.bone:b.color==='red'?col.red:col.ash,path?.68:selectedEdge?.7:hairAlpha);ctx.lineWidth=path?1.65:selectedEdge?1.4:1;curve(b,p);ctx.stroke();
var age=t-b.born;if(animate&&age>=0&&age<1400){var f=clamp(age/1150,0,1),pt=pointOn(b,p,f);ctx.shadowBlur=10;ctx.shadowColor=path?col.ember:col.blue;ctx.fillStyle=path?col.molten:col.blue;ctx.beginPath();ctx.arc(pt.x,pt.y,path?2.2:1.65,0,TAU);ctx.fill();ctx.shadowBlur=0;}
});});
var S=compact?6.5:clamp(lh*.22,minNode,13);
blocks.forEach(function(b){if(!b.on)return;
var age=t-b.born,newness=animate?Math.max(clamp(1-age/1700,0,1),clamp(1-(t-b.changed)/900,0,1)*.65):0;
var ink=b.color==='red'?col.red:b.chain?col.ember:b.color==='blue'?col.blue:col.ash;
var isOwn=own(b),isSelected=b.hash===selected||b.hash===hover,inOthers=!laneOf.has(b.miner),sz=S*(1+(newness*.15))*(inOthers?othersScale:1);
if(newness>0||isOwn||isSelected){var glow=ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,sz*3.8);glow.addColorStop(0,rgba(isOwn?col.molten:ink,newness*.26+(isSelected?.15:isOwn?.06:0)));glow.addColorStop(1,rgba(ink,0));ctx.fillStyle=glow;ctx.fillRect(b.x-sz*4,b.y-sz*4,sz*8,sz*8);}
ctx.globalAlpha=(b.color==='red'?.78:1)*(inOthers&&!isSelected&&!isOwn?othersScale:1);
// a proven block glows and carries a ring, so the proof reads from across the room
if(provenGlow&&b.proven){var pg=ctx.createRadialGradient(b.x,b.y,0,b.x,b.y,sz*3.2);pg.addColorStop(0,rgba(ink,.26));pg.addColorStop(1,rgba(ink,0));ctx.fillStyle=pg;ctx.fillRect(b.x-sz*3.4,b.y-sz*3.4,sz*6.8,sz*6.8);ctx.strokeStyle=rgba(ink,.6);ctx.lineWidth=1;ctx.beginPath();ctx.arc(b.x,b.y,sz+5,0,TAU);ctx.stroke();}
// Inset tiles: inclusion is the outline; proof is the core, never the same status.
ctx.fillStyle=col.bg;ctx.strokeStyle=rgba(ink,b.chain?.95:.8);ctx.lineWidth=b.chain?1.75:1.25;rounded(ctx,b.x-sz,b.y-sz,2*sz,2*sz,3);ctx.fill();ctx.stroke();
var tile=ctx.createLinearGradient(b.x,b.y-sz,b.x,b.y+sz);tile.addColorStop(0,rgba(ink,b.proven?.5:.16));tile.addColorStop(1,rgba(ink,b.proven?.22:.025));ctx.fillStyle=tile;rounded(ctx,b.x-sz+2,b.y-sz+2,2*sz-4,2*sz-4,2);ctx.fill();
if(b.proven){ctx.strokeStyle=rgba(b.color==='red'?col.red:col.bone,.9);ctx.lineWidth=1.3;ctx.beginPath();ctx.moveTo(b.x-3,b.y);ctx.lineTo(b.x-1,b.y+2);ctx.lineTo(b.x+3,b.y-2);ctx.stroke();}
else if(b.color==='red'){ctx.strokeStyle=ink;ctx.lineWidth=1.1;ctx.beginPath();ctx.moveTo(b.x-2,b.y-2);ctx.lineTo(b.x+2,b.y+2);ctx.moveTo(b.x+2,b.y-2);ctx.lineTo(b.x-2,b.y+2);ctx.stroke();}
else{ctx.fillStyle=rgba(ink,.65);ctx.fillRect(b.x-1.3,b.y-1.3,2.6,2.6);}
if(!compact && b.shards.length){var total=b.shards.length,shown=Math.min(total,8),gap=1.6,barW=2*sz,seg=(barW-gap*(shown-1))/shown;
for(var k=0;k<shown;k++){var shard=b.shards[k].state,alpha=shard==='proving'?(animate?.5+.4*Math.sin(t/300+k):.8):1;
ctx.fillStyle=rgba(shard==='paid'?col.molten:shard==='verified'?col.bone:shard==='proving'?col.ember:col.line2,alpha);ctx.fillRect(b.x-sz+k*(seg+gap),b.y+sz+4,Math.max(1,seg),2);
}
}
if(isOwn){ctx.strokeStyle=col.molten;ctx.lineWidth=1.2;var q=sz+4;ctx.beginPath();[[1,1],[1,-1],[-1,1],[-1,-1]].forEach(function(v){ctx.moveTo(b.x+v[0]*(q-4),b.y+v[1]*q);ctx.lineTo(b.x+v[0]*q,b.y+v[1]*q);ctx.lineTo(b.x+v[0]*q,b.y+v[1]*(q-4));});ctx.stroke();}
if(b.locked){ctx.strokeStyle=rgba(col.ember,.6);ctx.lineWidth=1;ctx.beginPath();ctx.arc(b.x,b.y,sz+8,0,TAU);ctx.stroke();}
if(b.final){ctx.fillStyle=rgba(col.ember,.75);ctx.beginPath();ctx.arc(b.x+sz-1,b.y-sz-2,2,0,TAU);ctx.fill();}
if(isSelected){ctx.strokeStyle=col.bone;ctx.lineWidth=1;ctx.setLineDash([3,3]);rounded(ctx,b.x-sz-8,b.y-sz-8,2*sz+16,2*sz+16,6);ctx.stroke();ctx.setLineDash([]);}
ctx.globalAlpha=1;
});ctx.restore();
// Timeline and feed edge. No fabricated DAA ticks or estimated checkpoint positions.
ctx.font=(compact?'8':'9')+'px ui-monospace, monospace';ctx.fillStyle=col.ash;ctx.textBaseline='middle';
var ticks=W<420?2:4;
for(var q=0;q<=ticks;q++){var tx=padL+plotW*q/ticks;ctx.textAlign=q===0?'left':q===ticks?'right':'center';var label=q===ticks?(paused?'PAUSED':!following?'HISTORY':state==='live'?'OBSERVED NOW':state.toUpperCase()):'-'+Math.round(windowS*(1-q/ticks))+'s';ctx.fillText(label,tx,H-(compact?8:13));}
if(!compact){var visibleBlocks=blocks.filter(function(b){return b.on;});ctx.font='8px ui-monospace, monospace';ctx.textAlign='left';ctx.fillStyle=rgba(col.ash,.8);if(padL>50)ctx.fillText('HEADER TIME',12,H-13);
if(visibleBlocks.length && W>600){var last=visibleBlocks[visibleBlocks.length-1];ctx.textAlign='right';ctx.fillText('DAA '+fmt(last.daa),W-padR,8);}
}
if (paused) { ctx.fillStyle = rgba(COL.obsidian, 0.0); }
if(state==='live'&&following&&!paused){ctx.strokeStyle=rgba(col.ember,.3);ctx.beginPath();ctx.moveTo(W-padR,padT-9);ctx.lineTo(W-padR,H-padB);ctx.stroke();ctx.fillStyle=col.ember;ctx.beginPath();ctx.arc(W-padR,padT-12,2.3,0,TAU);ctx.fill();}
if(!blocks.some(function(b){return b.on;})){
ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.font='12px system-ui, sans-serif';ctx.fillText(state==='connecting'?'Waiting for observer data':filter==='mine'?'No blocks for your key in this window':filter==='chain'?'No selected-chain blocks in this window':'No blocks in this time window',padL+plotW/2,H/2);
}
var active=selected?view.get(selected):hover?view.get(hover):null;if(active&&active.on)showTip(active);else if(tip)tip.hidden=true;
lastT=t;
}
function rr(x, y, w, h, r) { ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); }
function frame(t) { if (!running) return; if (!paused) draw(t); if (reduce || document.hidden || !inView) { running = false; return; } requestAnimationFrame(frame); }
function start() { if (running) return; running = true; requestAnimationFrame(frame); }
function kick() { if (!running) requestAnimationFrame(function (t) { draw(t); if (!reduce && !document.hidden && inView) start(); }); }
// ---- interaction ----
function hit(ev) {
var rect = canvas.getBoundingClientRect(), px = ev.clientX - rect.left, py = ev.clientY - rect.top, best = null, bd = 14 * 14;
blocks.forEach(function (b) { if (!b.on) return; var dx = b.x - px, dy = b.y - py, d = dx * dx + dy * dy; if (d < bd) { bd = d; best = b; } });
return best;
function continuous(){return !destroyed&&!doc.hidden&&visible&&!paused&&!reduced&&manualMotion&&state==='live'&&blocks.length>0;}
function frame(t){raf=0;if(destroyed||doc.hidden||!visible)return;if(t-lastPaint>=minFrame-.5){draw(t);lastPaint=t;}if(continuous())raf=requestAnimationFrame(frame);}
function redraw(){if(destroyed||raf||doc.hidden||!visible)return;lastPaint=-Infinity;raf=requestAnimationFrame(frame);}
function stopFrame(){if(raf)cancelAnimationFrame(raf);raf=0;}
function words(b){
if(!b)return {title:'No block selected',lines:[]};
var inclusion=b.color==='blue'?'included':b.color==='red'?'excluded':'pending';
var bits=[inclusion];if(b.chain)bits.push('selected chain');if(b.proven)bits.push('proven');if(b.locked)bits.push('locked checkpoint');if(b.final)bits.push('final');
return {title:b.hash+' '+bits.join(' / '),lines:[(own(b)?'Your block / ':'')+'miner key '+b.miner,
'Blue score '+fmt(b.blue_score===undefined?b.blue:b.blue_score)+' / DAA '+fmt(b.daa),b.parents.length+' parent'+(b.parents.length===1?'':'s'),
'Shards: '+(b.shards.length?b.shards.map(function(s){return s.state;}).join(', '):'no shard plan yet')]};
}
function words(b) {
var state = b.locked ? 'locked checkpoint' : b.proven ? 'proven' : b.chain ? 'included, selected chain' : b.color === 'blue' ? 'included' : b.color === 'red' ? 'excluded' : 'pending';
var sh = b.shards.length ? b.shards.map(function (s) { return s.state; }).join(', ') : 'no shard plan yet';
return { title: b.hash + ' ' + state, lines: [(isMine && isMine(b) ? 'your block, miner ' : 'miner ') + b.miner + (b.miner in laneOf ? '' : ' (others lane)'), 'blue score ' + fmt(b.blue) + ' daa ' + fmt(b.daa), b.parents.length + (b.parents.length === 1 ? ' parent' : ' parents'), 'proof: ' + sh + (b.final ? ' final' : '')] };
function showTip(b){
if(!tip)return;
if(!b){tip.hidden=true;return;}
var w=words(b),key=w.title+'|'+w.lines.join('|');
if(tip.dataset.igneumKey!==key){tip.replaceChildren();var title=doc.createElement('b');title.textContent=w.title;tip.appendChild(title);w.lines.forEach(function(line){var p=doc.createElement('span');p.textContent=line;p.style.display='block';tip.appendChild(p);});tip.dataset.igneumKey=key;}
tip.hidden=false;tip.style.pointerEvents='none';tip.style.position='absolute';tip.style.left='0px';tip.style.top='0px';tip.style.maxWidth=Math.max(160,W-24)+'px';tip.style.whiteSpace='normal';tip.style.overflowWrap='anywhere';
var host=tip.offsetParent||canvas.parentElement,cr=canvas.getBoundingClientRect(),pr=host.getBoundingClientRect();
var tw=tip.offsetWidth,th=tip.offsetHeight;var x=clamp(b.x+16,8,Math.max(8,W-tw-8)),y=b.y-th-18;if(y<8)y=Math.min(H-th-8,b.y+22);
tip.style.transform='translate('+Math.round(cr.left-pr.left-host.clientLeft+x)+'px,'+Math.round(cr.top-pr.top-host.clientTop+Math.max(8,y))+'px)';
}
var tip = opts.tooltip || null;
function showTip(b, ev) {
if (!tip) return;
if (!b) { tip.hidden = true; return; }
var w = words(b); tip.hidden = false; tip.innerHTML = '<b>' + w.title + '</b>' + w.lines.map(function (l) { return '<span>' + l + '</span>'; }).join('');
var rect = canvas.getBoundingClientRect(); var x = Math.min(rect.width - tip.offsetWidth - 8, Math.max(8, b.x + 12)), y = Math.max(8, b.y - tip.offsetHeight - 10); if (y < 8) y = b.y + 14;
tip.style.transform = 'translate(' + Math.round(x) + 'px,' + Math.round(y) + 'px)';
function hit(ev){var p=eventXY(ev),best=null,dist=22*22;blocks.forEach(function(b){if(!b.on)return;var d=(b.x-p.x)**2+(b.y-p.y)**2;if(d<dist){dist=d;best=b;}});return best;}
function select(hash,why){if(hash!==null&&!view.has(hash))return false;selected=hash;showTip(hash?view.get(hash):null);emit('onSelect',hash?cloneBlock(view.get(hash)):null,why||'select');redraw();return true;}
function engage(onValue){var value=!!onValue;if(engaged===value)return;engaged=value;canvas.classList.toggle('engaged',value);if(chip)chip.hidden=!value;emit('onEngage',value);}
function setWindow(seconds){var n=Math.round(clamp(seconds,30,300));if(!finite(n)||n===windowS)return;windowS=n;layout();emit('onWindow',n);emit('onStats',stats());poll();redraw();}
function zoom(f){if(!finite(f)||f<=0)return;setWindow(windowS*f);}
function follow(){following=true;fixedRight=0;if(paused)pausedRight=Date.now()-lag;layout();emit('onFollow',true);redraw();}
function setPaused(p){p=!!p;if(paused===p)return;
if(p){pausedRight=rightTime();freezeT=performance.now();paused=true;stopFrame();}
else{paused=false;syncView();}
if(opts.pauseButton){opts.pauseButton.setAttribute('aria-pressed',String(paused));opts.pauseButton.textContent=paused?'Resume':'Pause';}
emit('onPause',paused);emit('onStats',stats());redraw();
}
canvas.addEventListener('mousemove', function (ev) { hover = hit(ev); canvas.style.cursor = hover ? 'pointer' : 'default'; if (!pinned) showTip(hover, ev); kick(); });
canvas.addEventListener('mouseleave', function () { hover = null; if (!pinned) showTip(null); kick(); });
canvas.addEventListener('click', function (ev) { engage(true); var b = hit(ev); pinned = (pinned && b && pinned.hash === b.hash) ? null : b; showTip(pinned || hover); kick(); });
// the wheel is never captured by default (6 Oct 2026, the owner: scrolling past the scene zoomed it): the page scrolls through the
// scene; zoom takes ctrl or cmd with the wheel, a pinch, or a click into the scene, which engages it (opts.chip shows
// "scroll to zoom, Esc to release"); Escape, a click outside or leaving the scene releases it
var engaged = false, chip = opts.chip || null;
function engage(on) { if (on === engaged) return; engaged = on; canvas.classList.toggle('engaged', on); if (chip) chip.hidden = !on; if (opts.onEngage) opts.onEngage(on); }
canvas.addEventListener('wheel', function (ev) {
var zoomKey = ev.ctrlKey || ev.metaKey;
if (!zoomKey && !engaged) return; // a plain wheel or a two-finger scroll passes through to the page
if (Math.abs(ev.deltaY) < 1) return;
ev.preventDefault(); zoom(ev.deltaY > 0 ? 1.15 : 1 / 1.15);
}, { passive: false });
document.addEventListener('keydown', function (ev) { if (ev.key === 'Escape' && engaged) engage(false); });
document.addEventListener('pointerdown', function (ev) { if (engaged && !canvas.contains(ev.target) && !(chip && chip.contains(ev.target))) engage(false); });
var pinch = null;
canvas.addEventListener('touchstart', function (ev) { if (ev.touches.length === 2) pinch = dist(ev.touches); }, { passive: true });
canvas.addEventListener('touchmove', function (ev) { if (ev.touches.length === 2 && pinch) { var d = dist(ev.touches); if (Math.abs(d - pinch) > 12) { zoom(d > pinch ? 1 / 1.1 : 1.1); pinch = d; } } }, { passive: true });
canvas.addEventListener('touchend', function () { pinch = null; }, { passive: true });
function dist(t) { var dx = t[0].clientX - t[1].clientX, dy = t[0].clientY - t[1].clientY; return Math.sqrt(dx * dx + dy * dy); }
function zoom(f) { var w = Math.round(Math.max(30, Math.min(300, windowS * f))); if (w === windowS) return; windowS = w; layout(); if (opts.onWindow) opts.onWindow(w); poll(); kick(); }
function setPaused(p) { paused = !!p; if (paused) { offsetMs = 0; pausedAt = Date.now(); } else { offsetMs = 0; } if (opts.pauseButton) { opts.pauseButton.setAttribute('aria-pressed', paused ? 'true' : 'false'); opts.pauseButton.textContent = paused ? 'Resume' : 'Pause'; } kick(); }
var pausedAt = 0;
if (opts.pauseButton) opts.pauseButton.addEventListener('click', function () { setPaused(!paused); });
// while paused the clock stops: offsetMs grows so the scene holds still but keeps its blocks
setInterval(function () { if (paused) offsetMs = Date.now() - pausedAt; }, 250);
if ('IntersectionObserver' in root) { new IntersectionObserver(function (es) { es.forEach(function (e) { inView = e.isIntersecting; if (inView) kick(); }); }, { threshold: 0.05 }).observe(canvas); }
document.addEventListener('visibilitychange', function () { if (!document.hidden) { poll(); kick(); } });
if (root.matchMedia) { try { root.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () { readTheme(); kick(); }); } catch (e) { } }
new MutationObserver(function () { readTheme(); kick(); }).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
readTheme(); size(); if (opts.poll !== false) { poll(); setInterval(poll, 2000); } kick();
return { push: push, setPaused: setPaused, zoom: zoom, engage: engage, getWindow: function () { return windowS; }, getState: function () { return { state: state, reason: reason }; }, redraw: kick, words: words };
function setFilter(value){if(!['all','chain','mine'].includes(value))return;filter=value;select(null,'filter');layout();redraw();}
function setMotion(value){manualMotion=!!value;reducedRight=Date.now()-lag;stopFrame();redraw();}
function panTo(right){following=false;fixedRight=Math.min(right,Date.now()-lag);if(paused)pausedRight=fixedRight;emit('onFollow',false);layout();redraw();}
canvas.setAttribute('tabindex',canvas.getAttribute('tabindex')||'0');canvas.setAttribute('role','img');
if(!canvas.getAttribute('aria-label'))canvas.setAttribute('aria-label','Interactive block graph. Arrow keys inspect blocks. Plus and minus zoom. Space pauses, F follows, Escape releases.');
canvas.style.touchAction='pan-y';
on(canvas,'pointerdown',function(ev){if(ev.pointerType==='touch'||ev.button!==0)return;canvas.focus({preventScroll:true});engage(true);drag={id:ev.pointerId,x:ev.clientX,start:rightTime(),moved:false};canvas.setPointerCapture(ev.pointerId);});
on(canvas,'pointermove',function(ev){
if(drag&&drag.id===ev.pointerId){var dx=ev.clientX-drag.x;if(Math.abs(dx)>4)drag.moved=true;if(drag.moved){panTo(drag.start-dx*windowS*1000/Math.max(1,W-padL-padR));canvas.style.cursor='grabbing';return;}}
var b=hit(ev);hover=b?b.hash:null;canvas.style.cursor=b?'pointer':'grab';if(!selected)showTip(b);if(!continuous())redraw();
});
on(canvas,'pointerup',function(ev){if(ev.pointerType==='touch')return;if(!drag||drag.id!==ev.pointerId)return;var moved=drag.moved;drag=null;if(canvas.hasPointerCapture(ev.pointerId))canvas.releasePointerCapture(ev.pointerId);canvas.style.cursor='grab';if(!moved){var b=hit(ev);select(b&&selected!==b.hash?b.hash:null,'pointer');}});
on(canvas,'pointercancel',function(){drag=null;});
on(canvas,'pointerleave',function(){hover=null;if(!drag)engage(false);if(!selected)showTip(null);redraw();});
on(canvas,'wheel',function(ev){if(!ev.ctrlKey&&!ev.metaKey&&!engaged)return;if(Math.abs(ev.deltaY)<1)return;ev.preventDefault();zoom(ev.deltaY>0?1.15:1/1.15);},{passive:false});
var touchStart=null;
function dist(touches){return Math.hypot(touches[0].clientX-touches[1].clientX,touches[0].clientY-touches[1].clientY);}
on(canvas,'touchstart',function(ev){if(ev.touches.length===2){pinch={distance:dist(ev.touches),window:windowS};touchStart=null;}else if(ev.touches.length===1){touchStart={x:ev.touches[0].clientX,y:ev.touches[0].clientY};}},{passive:true});
on(canvas,'touchmove',function(ev){if(ev.touches.length===2&&pinch){ev.preventDefault();setWindow(pinch.window*pinch.distance/Math.max(1,dist(ev.touches)));}else if(touchStart&&ev.touches.length){if(Math.hypot(ev.touches[0].clientX-touchStart.x,ev.touches[0].clientY-touchStart.y)>8)touchStart=null;}},{passive:false});
on(canvas,'touchend',function(ev){if(pinch){if(ev.touches.length<2)pinch=null;touchStart=null;return;}if(touchStart&&ev.changedTouches.length){var b=hit(ev.changedTouches[0]);select(b&&selected!==b.hash?b.hash:null,'touch');touchStart=null;}},{passive:true});
on(canvas,'keydown',function(ev){
var key=ev.key;if(['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Home','End'].includes(key)){
ev.preventDefault();var list=blocks.filter(function(b){return b.on;}),i=list.findIndex(function(b){return b.hash===selected;}),next;
if(!list.length)return;if(key==='Home')next=0;else if(key==='End')next=list.length-1;else next=i<0?list.length-1:clamp(i+(['ArrowRight','ArrowDown'].includes(key)?1:-1),0,list.length-1);select(list[next].hash,'keyboard');
}else if(key===' '){ev.preventDefault();setPaused(!paused);}else if(key==='+'||key==='='){ev.preventDefault();zoom(1/1.2);}else if(key==='-'){ev.preventDefault();zoom(1.2);}else if(key.toLowerCase()==='f'){ev.preventDefault();follow();}
});
on(doc,'keydown',function(ev){if(ev.key==='Escape'&&(engaged||doc.activeElement===canvas)){engage(false);select(null,'escape');}});
on(doc,'pointerdown',function(ev){if(engaged&&ev.target!==canvas&&!(chip&&chip.contains(ev.target)))engage(false);});
if(opts.pauseButton){opts.pauseButton.setAttribute('aria-pressed','false');on(opts.pauseButton,'click',function(){setPaused(!paused);});}
on(root,'resize',size);
if('ResizeObserver'in root){ro=new ResizeObserver(size);ro.observe(canvas);}
if('IntersectionObserver'in root){io=new IntersectionObserver(function(es){visible=es[0].isIntersecting;if(visible)redraw();else stopFrame();},{threshold:0});io.observe(canvas);}
if('MutationObserver'in root){mo=new MutationObserver(theme);mo.observe(doc.documentElement,{attributes:true,attributeFilter:['data-theme','class','style']});}
if(mq&&mq.addEventListener)on(mq,'change',function(e){reduced=e.matches;reducedRight=Date.now()-lag;stopFrame();redraw();});
if(mediaTheme&&mediaTheme.addEventListener)on(mediaTheme,'change',theme);
on(doc,'visibilitychange',function(){if(doc.hidden){stopFrame();clearTimeout(pollTimer);if(controller)controller.abort();}else{poll();redraw();}});
healthTimer=setInterval(function(){if(destroyed||doc.hidden||!lastGood)return;if(state==='live'&&Date.now()-lastGood>(finite(opts.staleAfterMs)?opts.staleAfterMs:15000))setState('stale','No observer update for '+Math.round((Date.now()-lastGood)/1000)+' s');},1000);
function destroy(){if(destroyed)return;destroyed=true;stopFrame();clearTimeout(pollTimer);clearTimeout(timeout);clearInterval(healthTimer);if(controller)controller.abort();remove.forEach(function(fn){fn();});if(ro)ro.disconnect();if(io)io.disconnect();if(mo)mo.disconnect();if(tip)tip.hidden=true;canvas.classList.remove('engaged');
[['tabindex',initialAttributes.tabindex],['role',initialAttributes.role],['aria-label',initialAttributes.label]].forEach(function(pair){if(pair[1]===null)canvas.removeAttribute(pair[0]);else canvas.setAttribute(pair[0],pair[1]);});canvas.style.touchAction=initialAttributes.touch;canvas.style.cursor=initialAttributes.cursor;instances.delete(canvas);model.clear();view.clear();blocks=[];
}
var api={push:push,setPaused:setPaused,zoom:zoom,engage:engage,getWindow:function(){return windowS;},getState:function(){return {state:state,reason:reason};},redraw:redraw,words:words,
destroy:destroy,follow:follow,setWindow:setWindow,setFilter:setFilter,setMotion:setMotion,select:select,getStats:stats,
getViewRange:function(){return {start:rightTime()-windowS*1000,end:rightTime(),filter:filter};},getBlocks:function(){return blocks.map(cloneBlock);},getCheckpoints:function(){return cps.map(function(c){return Object.assign({},c);});},
getSnapshot:function(){return {ok:true,state:{stale:state!=='live'},blocks:blocks.map(cloneBlock),finality:{checkpoints:cps.map(function(c){return Object.assign({},c);})}};}};
instances.set(canvas,api);if(chip)chip.hidden=true;theme();size();emit('onState',state,reason);if(opts.poll!==false)poll();redraw();return api;
}
root.IgneumDag = { mount: mount };
root.IgneumDag={mount:mount,version:'2.0.1'};
})(window);

View file

@ -0,0 +1,72 @@
/* Igneum proof detail. A data-driven companion, not a mining-progress estimate.
* One orbital tile per displayed shard (up to 12). No time-based status promotion.
* IgneumProof.mount(canvas).setBlock(observerBlock); destroy() on unmount.
*/
(function(root){
'use strict';
var mounted=new WeakMap(),TAU=Math.PI*2;
function mount(canvas,opts){
opts=opts||{};if(mounted.has(canvas))mounted.get(canvas).destroy();
var ctx=canvas.getContext('2d');if(!ctx)throw new Error('A 2D canvas context is required.');
var W=0,H=0,dpr=1,block=null,raf=0,disposed=false,paused=false,inView=true,lastPaint=0,frames=0,force=true;
var mq=matchMedia('(prefers-reduced-motion: reduce)'),reduce=mq.matches,motion=true,col={},ro,io,mo;
var phase=0,lastT=0;
function alpha(c,a){if(/^#[a-f\d]{6}$/i.test(c))return 'rgba('+parseInt(c.slice(1,3),16)+','+parseInt(c.slice(3,5),16)+','+parseInt(c.slice(5,7),16)+','+a+')';return c;}
function theme(){var s=getComputedStyle(document.documentElement);function c(k,f){return s.getPropertyValue(k).trim()||f;}col={ember:c('--ember','#F2541B'),gold:c('--molten','#FFB35C'),bone:c('--bone','#F4F1EC'),ash:c('--ash','#9A9A9E'),line:c('--line','#2A2A30'),surface:c('--graphite','#16161A'),bg:c('--row','#111114')};kick();}
function size(){var nw=canvas.clientWidth,nh=canvas.clientHeight,nd=Math.min(devicePixelRatio||1,2);if(W===nw&&H===nh&&dpr===nd&&canvas.width===Math.round(nw*nd)){kick();return;}W=nw;H=nh;dpr=nd;canvas.width=W*dpr;canvas.height=H*dpr;ctx.setTransform(dpr,0,0,dpr,0,0);kick();}
function poly(points,fill,stroke){ctx.beginPath();points.forEach(function(p,i){if(i===0)ctx.moveTo(p[0],p[1]);else ctx.lineTo(p[0],p[1]);});ctx.closePath();if(fill){ctx.fillStyle=fill;ctx.fill();}if(stroke){ctx.strokeStyle=stroke;ctx.stroke();}}
function iso(x,y,w,h,depth,fill,stroke){
poly([[x-w,y],[x,y+h],[x,y+h+depth],[x-w,y+depth]],alpha(fill,.17),alpha(stroke,.4));
poly([[x,y+h],[x+w,y],[x+w,y+depth],[x,y+h+depth]],alpha(fill,.07),alpha(stroke,.32));
poly([[x,y-h],[x+w,y],[x,y+h],[x-w,y]],col.bg,stroke);
poly([[x,y-h],[x+w,y],[x,y+h],[x-w,y]],alpha(fill,.15),null);
}
function line(a,b,c,w){ctx.strokeStyle=c;ctx.lineWidth=w||1;ctx.beginPath();ctx.moveTo(a[0],a[1]);ctx.lineTo(b[0],b[1]);ctx.stroke();}
function draw(t){
if(!W||!H||disposed)return;frames++;
if(lastT&&!reduce&&motion&&!paused)phase+=Math.min(t-lastT,70)/1000;lastT=t;
ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,W,H);
var x=W*.5,y=H*.49,scale=Math.min(W/320,H/240),cw=43*scale,ch=23*scale;
var shards=block?block.shards||[]:[],active=shards.some(function(s){return s.state==='proving';});
var glow=ctx.createRadialGradient(x,y,0,x,y,W*.43);glow.addColorStop(0,alpha(col.ember,.10));glow.addColorStop(1,alpha(col.ember,0));ctx.fillStyle=glow;ctx.fillRect(0,0,W,H);
// A stationary coordinate grid, not fabricated hashrate or work counters.
for(var k=-3;k<=3;k++){line([x-130*scale,y+k*18*scale-46*scale],[x+130*scale,y+k*18*scale+46*scale],alpha(col.line,.45));line([x-130*scale,y+k*18*scale+46*scale],[x+130*scale,y+k*18*scale-46*scale],alpha(col.line,.45));}
ctx.strokeStyle=alpha(col.line,.7);ctx.lineWidth=1;ctx.setLineDash([2,6]);ctx.beginPath();ctx.ellipse(x,y+10*scale,106*scale,59*scale,0,0,TAU);ctx.stroke();ctx.setLineDash([]);
var count=Math.min(12,shards.length);
for(var i=0;i<count;i++){
var angle=-Math.PI/2+i/count*TAU,px=x+Math.cos(angle)*108*scale,py=y+Math.sin(angle)*57*scale;
var state=shards[i].state,ink=state==='proving'?col.ember:state==='paid'?col.gold:state==='verified'?col.bone:col.ash;
var midx=(px+x)/2,midy=(py+y)/2;
ctx.strokeStyle=alpha(ink,state==='planned'?.14:.38);ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(px,py);ctx.quadraticCurveTo(midx,midy-12*scale,x,y);ctx.stroke();
if(state==='proving'&&!reduce&&motion&&!paused){var p=(phase*.48+i*.33)%1,xx=(1-p)*(1-p)*px+2*(1-p)*p*midx+p*p*x,yy=(1-p)*(1-p)*py+2*(1-p)*p*(midy-12*scale)+p*p*y;ctx.fillStyle=col.gold;ctx.shadowBlur=8;ctx.shadowColor=col.ember;ctx.beginPath();ctx.arc(xx,yy,1.8*scale,0,TAU);ctx.fill();ctx.shadowBlur=0;}
iso(px,py,13*scale,7*scale,5*scale,ink,alpha(ink,.72));
if(state==='verified'||state==='paid'){ctx.strokeStyle=ink;ctx.lineWidth=1.2;ctx.beginPath();ctx.moveTo(px-4*scale,py);ctx.lineTo(px-1*scale,py+2*scale);ctx.lineTo(px+4*scale,py-2*scale);ctx.stroke();}
else{ctx.fillStyle=alpha(ink,state==='proving'?.9:.4);ctx.fillRect(px-1.5*scale,py-1.5*scale,3*scale,3*scale);}
ctx.font='8px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('S'+String(i+1).padStart(2,'0'),px,py+22*scale);
}
// Layered block core. Geometry does not imply three GPUs or three proofs.
var lift=active&&!reduce&&motion&&!paused?Math.sin(phase*1.7)*1.8*scale:0;
ctx.lineWidth=1;
iso(x,y+21*scale,cw,ch,10*scale,col.ember,alpha(col.ember,.34));
iso(x,y+6*scale,cw,ch,8*scale,col.ember,alpha(col.ember,.55));
iso(x,y-11*scale-lift,cw,ch,7*scale,col.ember,alpha(col.ember,.85));
var topY=y-11*scale-lift;
poly([[x,topY-13*scale],[x+25*scale,topY],[x,topY+13*scale],[x-25*scale,topY]],alpha(col.ember,block&&block.proven?.28:.06),alpha(col.gold,.46));
if(block&&block.proven){ctx.strokeStyle=col.gold;ctx.lineWidth=1.8;ctx.beginPath();ctx.moveTo(x-7*scale,topY);ctx.lineTo(x-2*scale,topY+4*scale);ctx.lineTo(x+8*scale,topY-4*scale);ctx.stroke();}
else{ctx.fillStyle=col.ember;ctx.fillRect(x-2*scale,topY-2*scale,4*scale,4*scale);}
if(block&&block.locked){ctx.strokeStyle=col.gold;ctx.lineWidth=1.2;ctx.beginPath();ctx.ellipse(x,y+12*scale,67*scale,38*scale,0,0,TAU);ctx.stroke();ctx.font='8px ui-monospace, monospace';ctx.fillStyle=col.gold;ctx.textAlign='center';ctx.fillText('LOCKED CHECKPOINT',x,H-12);}
else if(!block){ctx.font='10px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('SELECT A BLOCK',x,H-12);}
else if(shards.length>12){ctx.font='9px ui-monospace, monospace';ctx.textAlign='center';ctx.fillStyle=col.ash;ctx.fillText('+ '+(shards.length-12)+' more shards in the list',x,H-12);}
}
function continuous(){return !disposed&&!document.hidden&&inView&&!reduce&&motion&&!paused&&block&&(block.shards||[]).some(function(s){return s.state==='proving';});}
function frame(t){raf=0;if(disposed||document.hidden||!inView)return;if(force||t-lastPaint>=1000/30){draw(t);lastPaint=t;force=false;}if(continuous())raf=requestAnimationFrame(frame);}
function kick(){force=true;if(!disposed&&!document.hidden&&inView&&!raf)raf=requestAnimationFrame(frame);}
function stop(){if(raf)cancelAnimationFrame(raf);raf=0;lastT=0;}
function visibility(){if(document.hidden)stop();else kick();}
function reduction(e){reduce=e.matches;stop();kick();}
var api={setBlock:function(b){block=b?{hash:b.hash,proven:b.proven===true,locked:b.locked===true,shards:(b.shards||[]).map(function(s){return {state:s.state};})}:null;canvas.setAttribute('aria-label',block?'Block proof detail: '+block.shards.map(function(s,i){return 'shard '+(i+1)+' '+s.state;}).join(', '):'Select a block to inspect its proof shards');kick();},setPaused:function(value){paused=!!value;stop();kick();},setMotion:function(value){motion=!!value;stop();kick();},getStats:function(){return {frames:frames,reducedMotion:reduce||!motion};},destroy:function(){if(disposed)return;disposed=true;stop();ro.disconnect();if(io)io.disconnect();mo.disconnect();root.removeEventListener('resize',size);document.removeEventListener('visibilitychange',visibility);mq.removeEventListener('change',reduction);mounted.delete(canvas);}};
ro=new ResizeObserver(size);ro.observe(canvas);if('IntersectionObserver'in root){io=new IntersectionObserver(function(es){inView=es[0].isIntersecting;if(inView)kick();else stop();});io.observe(canvas);}mo=new MutationObserver(theme);mo.observe(document.documentElement,{attributes:true,attributeFilter:['data-theme','style','class']});
root.addEventListener('resize',size);document.addEventListener('visibilitychange',visibility);mq.addEventListener('change',reduction);mounted.set(canvas,api);theme();size();return api;
}
root.IgneumProof={mount:mount,version:'2.0.0'};
})(window);

View file

@ -465,3 +465,199 @@ test('whose node (publish 2): the app’s own, another node on this machine chec
assert.equal(taken.word, 'not started'); assert.equal(taken.tone, 'bad'); assert.match(taken.line, /^Node not started: port 26611 is taken/);
assert.deepEqual(V.pill({ setup_done: true, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'stopped', source: 'none' } }), { text: 'Node not started', tone: 'bad' });
});
// ---- miner-ui-5 (7 October 2026): the miner's first month ----
// The chain facts as GET api/ladder shapes them (src/chainfacts.rs) for one key, 6ad0e117 (the example key of the project lead's
// chain-scene reference), under the finality params the devnet node reports (api/live params, 7 Oct 2026: dust 5,
// weightWindow 7,200 s, presenceWindow 20, checkpointInterval 30; tools/fleet/devnet2-override.json sets none of them,
// so Devnet 2 runs the same values). Every number the rungs show is a field of getFinalityWeights,
// getFinalityCheckpoints, /api/stats or this app's own ladder.json, and the rung names it on hover.
const DN2 = { dust: 5, weight_window: 7200, presence_window: 20, checkpoint_interval: 30, min_daa: 7200 };
const MAINNET = { dust: 100, weight_window: 2592000, presence_window: 240, checkpoint_interval: 30, min_daa: 7200 };
const chainOf = (over, params) => ({ ok: true, source: 'node', rpc: { weights: 'getFinalityWeights', checkpoints: 'getFinalityCheckpoints', stats: '/api/stats', relay: '' }, params: params || DN2, checkpoint_index: 8495, latest_locked_index: 8495, next_index: 8496, finality_active: true, total_weight: 6000, active_weight: 5900, voters: 14, keys_listed: 4, keys_cap: 0, network: { hashrate_hps: 100e9, miner_ign_per_block: 80, ign_per_block: 100, ramp_factor: 1, daa: 1284117, blocks_per_day_measured: 86400, bps: 1, stale: false }, mine: [], best: null, ...over });
const key = (over) => ({ id: '6ad0e117', blocks: 0, voter: false, participation: 0, stripped_until_daa: 0, revealed: false, rank: 4, ...over });
const T5 = 1791362116;
const stateOf = (over) => ({ now: T5, uptime_s: 1800, version: '0.3.19', installed_at: T5 - 3000, setup_done: true, address: { display: '0xdd442fCbb964A3aFDc90D49B408e8DD296FA86E8' }, settings: { prove: false, dev_fee: true, power_price_pence: 28 }, node: { synced: true }, proving: { paid: 0, paid_wei: 0 }, mining: { state: 'mining', hash_total: 100, accepted_total: 0, accepted_session: 0, found: [], cards: [card({ key: 'nvidia:0:RTX 4070', name: 'NVIDIA GeForce RTX 4070', hash_now: 100, hash_avg: 100, power_w: 150, ids: ['6ad0e117'] })] }, ladder: { first_block_at: 0, first_block_card: '', first_block_hash: '', first_block_daa: 0, days_mined_30: 0, blocks_30d: 0, blocks_today: 0, days: [], cards_first: {}, blocks: [], votes_signed: 0, last_vote_index: 0, last_vote_at: 0, last_signed_locked: 0, signed_locked_count: 0, streak_s: 0, shards: [], milestone: null, first_synced_at: 0, first_mining_at: 0 }, ...over });
const RPC = /getFinalityWeights|getFinalityCheckpoints|igneum_getProofRecords|\/api\/stats|this app/;
test('the Poisson count-up: the gap from the card’s rate and the network’s, the chance from the time waited', () => {
const a = V.poisson(100, 100e9, 0, 1);
assert.equal(Math.round(a.gap_s), 1000);
assert.equal(a.gapText, '17 minutes');
assert.equal(a.chanceText, '0%');
assert.equal(a.line, 'A card like yours finds a block about every 17 minutes on today’s network. Chance so far: 0%.');
assert.equal(a.pool, '');
const b = V.poisson(17, 100e9, 1800, 1);
assert.equal(b.gapText, '1.6 hours');
assert.equal(b.chanceText, '26%');
const c = V.poisson(17, 1e12, 3600, 1);
assert.equal(c.gapText, '16 hours');
assert.match(c.pool, /^Above an 8-hour gap a pool pays by the share/);
assert.equal(V.poisson(100, 100e9, 1000, 1).chanceText, '63%');
assert.equal(V.poisson(17, 10e12, 0, 1).gapText, '6.8 days');
assert.equal(V.poisson(100, 100e9, 10, 1, true).line.indexOf('Your cards find'), 0);
assert.equal(V.poisson(0, 100e9, 10, 1), null);
assert.equal(V.poisson(100, 0, 10, 1), null);
// IGN a day: 86,400 blocks a day, the share of them, 80 producer points each (the litepaper's 6,912 example)
assert.equal(Math.round(V.ignPerDay(100, 100e9, 80, 1)), 6912);
assert.equal(V.ignPerDay(100, 0, 80, 1), null);
});
test('a Devnet 2 key walks every rung on screen, and every rung names its RPC field (the gate)', () => {
// 0. mining, nothing found: the first rung is "now", the rest wait; the count-up line is on
let s = stateOf(), c = chainOf();
let r = V.ladderRungs(s, c, T5);
assert.deepEqual(r.map((x) => x.id), ['first', 'vote', 'signed', 'window', 'rank', 'streak', 'shard']);
assert.deepEqual(r.map((x) => x.state), ['now', 'next', 'next', 'next', 'next', 'next', 'off']);
assert.equal(r[0].line, 'Waiting for your first block.');
assert.equal(V.currentRung(r).id, 'first');
r.forEach((x) => assert.match(x.field, RPC, x.id + ' names no RPC field: ' + x.field));
assert.equal(V.blockCard(null, s, c, T5), null);
// 1. the first block: the card raises, the key enters the table with one block under the dust line
const m1 = { kind: 'first', count: 1, card: 'nvidia:0:RTX 4070', card_name: 'NVIDIA GeForce RTX 4070', hash: '9f3a' + 'ab'.repeat(28) + 'c21e', daa: 1284117, at: T5 - 60 };
s = stateOf({ mining: { ...stateOf().mining, accepted_total: 1, found: [T5 - 60] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 60, first_block_card: 'nvidia:0:RTX 4070', first_block_hash: m1.hash, first_block_daa: 1284117, days_mined_30: 1, blocks_30d: 1, blocks_today: 1, milestone: m1 } });
c = chainOf({ mine: [key({ blocks: 1, rank: 4 })], best: key({ blocks: 1, rank: 4 }) });
r = V.ladderRungs(s, c, T5);
assert.deepEqual(r.map((x) => x.state), ['done', 'now', 'next', 'now', 'done', 'next', 'off']);
assert.equal(r[0].line, 'First block 1 min ago, NVIDIA GeForce RTX 4070.');
assert.equal(r[0].sub, '1 block with your key in the window');
assert.equal(r[1].line, '1 of 5 blocks to a vote.');
assert.equal(r[1].progress, 0.2);
assert.equal(r[3].line, '1 of 2 hours.');
assert.equal(r[4].line, 'Rank 4 of 4 keys.');
assert.equal(V.currentRung(r).id, 'vote');
const bc = V.blockCard(m1, s, c, T5);
assert.equal(bc.eyebrow, 'your first block');
assert.equal(bc.title, 'Block 1,284,117 is yours.');
assert.equal(bc.cardLine, 'NVIDIA GeForce RTX 4070 · 100 MH/s · 0.100% of the network');
assert.equal(bc.ignLine, '80.00 IGN to 0xdd44…86E8 (72 producer points, 8 for signing)');
assert.equal(bc.rankLine, 'rank 4 of 4 keys · hour 1 of 2');
assert.equal(bc.url, 'https://igneum.network/block/' + m1.hash);
assert.equal(bc.hashShort, '9f3aabab…abc21e');
assert.match(bc.fields.ign, /block_reward\.miner_ign/);
// 2. five blocks: the vote
c = chainOf({ mine: [key({ blocks: 5, voter: true, revealed: true, rank: 4 })], best: key({ blocks: 5, voter: true, revealed: true, rank: 4 }) });
r = V.ladderRungs(s, c, T5);
assert.equal(r[1].state, 'done');
assert.equal(r[1].line, 'Your key has a vote.');
assert.equal(r[1].sub, '5 blocks in the window, the line is 5');
assert.equal(r[2].state, 'now');
assert.equal(r[2].line, 'Signing: the next locked checkpoint carries your vote.');
assert.equal(r[5].state, 'now');
// 3. the signature in a locked checkpoint, the streak
s = stateOf({ ...s, ladder: { ...s.ladder, votes_signed: 31, last_vote_index: 8496, last_vote_at: T5 - 5, last_signed_locked: 8496, signed_locked_count: 30, streak_s: 900 } });
c = chainOf({ latest_locked_index: 8496, mine: [key({ blocks: 5, voter: true, participation: 1, rank: 4 })], best: key({ blocks: 5, voter: true, participation: 1, rank: 4 }) });
r = V.ladderRungs(s, c, T5);
assert.equal(r[2].state, 'done');
assert.equal(r[2].line, 'Your signature is in checkpoint 8,496.');
assert.equal(r[2].sub, '30 signed and locked · presence 100% · latest lock 8,496');
assert.equal(r[5].state, 'done');
assert.equal(r[5].line, 'Signing 8 of 80 points, 15 min unbroken.');
// 4. the full window: two hours on the devnet; the rank
s = stateOf({ ...s, ladder: { ...s.ladder, first_block_at: T5 - 7300, blocks_30d: 61, blocks_today: 61 } });
c = chainOf({ latest_locked_index: 8700, mine: [key({ blocks: 61, voter: true, participation: 1, rank: 3 })], best: key({ blocks: 61, voter: true, participation: 1, rank: 3 }) });
r = V.ladderRungs(s, c, T5);
assert.equal(r[3].state, 'done');
assert.equal(r[3].line, '2 of 2 hours. Full weight.');
assert.equal(r[4].line, 'Rank 3 of 4 keys.');
assert.equal(r[4].sub, '14 keys vote · weight, never hashrate: a card that arrived today sits at the bottom');
// P. a paid shard
s = stateOf({ ...s, settings: { ...s.settings, prove: true }, proving: { paid: 1, paid_wei: '1150000000000000000' }, ladder: { ...s.ladder, shards: [{ block: 1284117, shard: 3, wei: '1150000000000000000', at: T5 - 30 }] } });
r = V.ladderRungs(s, c, T5);
assert.equal(r[6].state, 'done');
assert.equal(r[6].line, 'Your card proved shard 3 of block 1,284,117.');
assert.equal(r[6].sub, '1.15 IGN · 1 paid');
assert.deepEqual(r.map((x) => x.state), ['done', 'done', 'done', 'done', 'done', 'done', 'done']);
r.forEach((x) => assert.match(x.field, RPC));
assert.equal(V.currentRung(r).id, 'shard');
// proving off: the rung reads off, never a fault
assert.equal(V.ladderRungs(stateOf(), chainOf(), T5)[6].line, 'Proving is off.');
});
test('the rungs under mainnet-sized params: 100 blocks to a vote, 30 days to full weight; the relay and the cap are named', () => {
const s = stateOf({ mining: { ...stateOf().mining, accepted_total: 61, found: [] }, ladder: { ...stateOf().ladder, first_block_at: T5 - 23 * 86400, days_mined_30: 23, blocks_30d: 61 } });
const c = chainOf({ source: 'observer', keys_listed: 64, keys_cap: 64, voters: 1204, mine: [key({ blocks: 61, rank: 41 })], best: key({ blocks: 61, rank: 41 }) }, MAINNET);
const r = V.ladderRungs(s, c, T5);
assert.equal(r[1].line, '61 of 100 blocks to a vote.');
assert.equal(r[3].line, '23 of 30 days.');
assert.equal(r[3].progress, 23 / 30);
assert.equal(r[4].line, 'Rank 41 of 64 keys.');
assert.equal(r[4].sub, '1,204 keys vote · the top 64 are listed · weight, never hashrate: a card that arrived today sits at the bottom');
// a key below the observer's 64 says so instead of inventing a rank
const below = V.ladderRungs(s, chainOf({ source: 'observer', keys_listed: 64, keys_cap: 64 }, MAINNET), T5);
assert.equal(below[4].line, 'Below the top 64 keys.');
assert.equal(below[1].sub, 'your key is not in the table yet');
// no chain facts at all: the words say so, nothing is invented
const none = V.ladderRungs(s, { ok: false, error: 'neither the node nor the observer answered' }, T5);
assert.equal(none[1].line, 'A vote needs the dust line’s blocks in the window.');
assert.equal(none[1].sub, 'neither the node nor the observer answered');
assert.equal(none[4].line, 'Rank: not read yet.');
assert.deepEqual(V.windowWords(2592000), { n: 30, unit: 'days', text: '30 days' });
assert.deepEqual(V.windowWords(7200), { n: 2, unit: 'hours', text: '2 hours' });
assert.deepEqual(V.windowWords(0), { n: 30, unit: 'days', text: '30 days' });
});
test('Earnings in IGN first: measured from the last hour, expected from the share, the typed price, the share and rank, IGN per kWh', () => {
// 100 MH/s on 100 GH/s is 86.4 blocks a day, 3.6 an hour: four in the last hour here
const found = [T5 - 3400, T5 - 2500, T5 - 1300, T5 - 200];
const s = stateOf({ mining: { ...stateOf().mining, accepted_total: 2592, found: found, watts_total: 150 }, proving: { paid: 14, paid_wei: '16100000000000000000' }, ladder: { ...stateOf().ladder, first_block_at: T5 - 23 * 86400, days_mined_30: 23, blocks_30d: 2592 } });
const c = chainOf({ source: 'observer', keys_listed: 1204, keys_cap: 64, voters: 1204, mine: [key({ blocks: 2592, voter: true, rank: 41 })], best: key({ blocks: 2592, voter: true, rank: 41 }) }, MAINNET);
const e = V.earningsLines(s, c, null, 28, T5);
assert.equal(e.day.text, '7,680 IGN a day');
assert.equal(e.day.sub, 'at your last hour’s rate (4 blocks, 80.00 IGN each) · about 6,912 IGN expected at 100 MH/s');
assert.match(e.day.field, /mining\.found.*block_reward\.miner_ign/);
assert.equal(e.window.text, '2,592 blocks in 30 days, 207,360 IGN');
assert.equal(e.window.sub, 'with your key in the weight window · at today’s reward · 14 shards, 16.10 IGN');
assert.equal(e.price.text, 'Type a price');
assert.equal(e.price.set, false);
assert.equal(e.share.text, 'your cards are 0.100% of the network · weight rank 41 of 1,204 keys · 23 of 30 days');
assert.match(e.share.sub, /through igneum\.network’s observer/);
assert.equal(e.perKwh.text, '2,133 IGN per kWh');
assert.equal(e.perKwh.sub, 'IGN a day over 3.6 kWh a day at 150 W');
// a typed price: £ a day from the IGN line, named as the user's figure
const p = V.earningsLines(s, c, 0.05, 28, T5);
assert.equal(p.price.text, 'about £384 a day');
assert.equal(p.price.sub, 'at £0.05 per IGN, your figure · no exchange lists IGN');
assert.match(p.price.field, /never fetched/);
// a fresh miner with no block in the hour: the expectation, said as one
const f = V.earningsLines(stateOf(), chainOf(), null, 28, T5);
assert.equal(f.day.text, 'about 6,912 IGN a day');
assert.equal(f.day.sub, 'expected at 100 MH/s, 0.100% of today’s network · no block in the last hour yet');
assert.equal(f.window.text, '0 blocks in 2 hours, 0.0000 IGN');
// no chain facts: nothing invented
const n = V.earningsLines(stateOf(), { ok: false, error: 'neither answered' }, 0.05, 28, T5);
assert.equal(n.day.text, 'IGN a day: reading the network');
assert.equal(n.day.sub, 'neither answered');
assert.equal(n.price.text, 'at £0.05 per IGN');
assert.equal(n.perKwh, null);
assert.equal(V.cardIgnDay(card({ hash_now: 25 }), chainOf()), '1,728');
assert.equal(V.cardIgnDay(card({ hash_now: 25 }), null), '');
assert.equal(V.fmtIgn(0.5), '0.5000 IGN'); assert.equal(V.fmtIgn(4.88044084), '4.88 IGN'); assert.equal(V.fmtIgn(207360), '207,360 IGN');
});
test('the block card on the four thresholds and on a new card; the first-hour timeline; the profile words', () => {
const s = stateOf(), c = chainOf({ mine: [key({ blocks: 100, voter: true, rank: 2 })], best: key({ blocks: 100, voter: true, rank: 2 }) });
const m = (kind, count, over) => ({ kind, count, card: 'nvidia:0:RTX 4070', card_name: 'NVIDIA GeForce RTX 4070', hash: 'ab'.repeat(32), daa: 1284117, at: T5 - 10, ...over });
assert.equal(V.blockCard(m('hundred', 100), s, c, T5).eyebrow, 'block 100 of this machine');
assert.equal(V.blockCard(m('thousand', 1000), s, c, T5).eyebrow, 'block 1,000 of this machine');
assert.equal(V.blockCard(m('ten_thousand', 20000), s, c, T5).eyebrow, 'block 20,000 of this machine');
assert.equal(V.blockCard(m('card', 7), s, c, T5).eyebrow, 'first block on NVIDIA GeForce RTX 4070');
assert.equal(V.blockCard(m('first', 1, { hash: '', daa: 0 }), s, c, T5).title, 'A block is yours.');
assert.equal(V.blockCard(m('first', 1, { hash: '', daa: 0 }), s, c, T5).url, '');
assert.equal(V.blockCard(m('first', 1), s, chainOf({ network: { ...chainOf().network, ramp_factor: 0.19, miner_ign_per_block: 4.88044084 } }), T5).ignLine, '4.88 IGN to 0xdd44…86E8 (72 producer points, 8 for signing, ramp 19%)');
assert.equal(V.blockCard(m('first', 1), s, c, T5).key, 'card:1:' + (T5 - 10));
const t = V.timeline(stateOf({ ladder: { ...stateOf().ladder, first_synced_at: T5 - 2800, first_mining_at: T5 - 2700, first_block_at: T5 - 2400 } }), T5);
assert.deepEqual(t.steps.map((x) => x.id), ['install', 'started', 'synced', 'mining', 'block', 'payout']);
assert.deepEqual(t.steps.map((x) => x.rel), ['+0 s', '+20 min', '+3 min', '+5 min', '+10 min', '+10 min']);
assert.match(t.steps[5].note, /coinbase of the first block/);
const u = V.timeline(stateOf({ installed_at: 0, uptime_s: 100 }), T5);
assert.equal(u.steps[0].note, 'not recorded on this install');
assert.equal(u.steps[2].note, 'the moment is not recorded; synced now');
assert.match(V.profileWords(true), /^Your address page on igneum\.network is public/);
assert.match(V.profileWords(false), /unlisted/);
assert.equal(V.gapWords(45), '45 seconds'); assert.equal(V.spanWords(3700), '1 h 1 min'); assert.equal(V.spanWords(3 * 86400), '3 days');
// the Activity lines for the milestones
assert.equal(V.plainEvent('first block: found by NVIDIA GeForce RTX 4070 (block card)').text, 'Your first block, found by NVIDIA GeForce RTX 4070');
assert.equal(V.plainEvent('block 1,000 of this machine: found by RTX 4070 (block card)').text, 'Block 1,000 of this machine, found by RTX 4070');
assert.equal(V.plainEvent('first block on Apple M5 Max (block card)').text, 'First block on Apple M5 Max');
});

View file

@ -0,0 +1,78 @@
# Discord: the ladder's rung announcements (message shapes, not posted)
7 October 2026, miner-ui-5 (mission item 6, docs/analysis/mission/mission.md 2.6). The shapes the bot would post for
each rung of the miner's ladder, so the words in Discord match the app and the site rung for rung. Nothing here is
posted by this lane; the Discord lane wires them into `tools/community/discord-hooks.mjs` behind its guard rails
(docs/community/discord-hooks.md: the forbidden-string guard, the limits, idempotency keys, never a mention). Every
number is a chain fact from the public API (`/api/live`, `/api/stats`), which is the observer's copy of the node's
`getFinalityWeights` and `getFinalityCheckpoints`; a post names nothing the chain does not say.
## The rules every rung post obeys
| Rule | How |
|---|---|
| The miner's object, never the project's | a post carries the key id, the block link, the rank and the day; never the network hashrate, the block count or a milestone of the project |
| A chain fact only | the trigger is a field of `getFinalityWeights` (`keys[].blocks`, `keys[].voter`, `keys[].participation`, `voters`) or `getFinalityCheckpoints` (`latestLockedIndex`), read through `/api/live finality.weights` |
| Opt-in for anything beyond the key id | the card model and the "(you)" link to the address page appear only when the miner switched "Make my page public" on in the app (settings.profile_public); without it the post is the key id and the block link |
| Short key id | the first 8 hex of the vote key hash, the same id the app, `/live` and the explorer show; never a payout address, never a machine name |
| One post per key per rung | idempotency key `rung:<rung>:<keyid>`; a rung is posted once for a key, ever (a key that drops and climbs back is not posted again) |
| Copy law | no em dashes, no two-beat antithesis, no aphorisms; the numbers in the line, nothing decorative |
| Volume | #first-blocks takes rungs 0 and 1 (one line each); #numbers takes the daily ladder summary; rungs 2 to 5 and P are never posted singly (one a minute at scale), they are counted in the summary |
## Channel: #first-blocks
### Rung 0, first block (trigger: a key's first blue block in the window; `keys[].blocks` goes 0 to 1 in `finality.weights`, and the block's `miner` is that key in `/api/live blocks[]`)
```
First block: key 6ad0e117
Block 1,284,117 · igneum.network/block/9f3a…c21e
RTX 4070 (the miner chose to show the card)
```
Without the opt-in the third line is absent. Embed: ember accent, title "First block", one field "Key" = `6ad0e117`, one field "Block" = the explorer link; footer the UK time with UTC in brackets (the hooks script's footer rule).
### Rung 1, the vote (trigger: `keys[].voter` goes false to true, that is `blocks` reaches `params.dust`)
```
Your key has a vote: 6ad0e117
100 blocks in the window (the line is 100) · the key now signs every checkpoint
```
On the devnet the line reads `5 blocks in the window (the line is 5)`: the number is `params.dust`, never a constant.
## Channel: #numbers, the daily ladder summary (09:00 UK, beside the daily digest)
```
The ladder, yesterday
First blocks: 14 keys found their first block
Votes: 9 keys crossed the dust line (now 1,213 voters)
Signatures: 1,201 keys signed a locked checkpoint (latest lock 184,220)
Full window: 406 keys at 30 of 30 days
Rank 1 by weight: 6e80f3ef with 2,592 blocks in the window
Signing streak, longest: 41 days unbroken (presence 100%)
Shards: 388 paid to 57 keys
```
Fields and sources, one per line:
| Line | Field |
|---|---|
| First blocks | keys whose `blocks` went from 0 to at least 1 between the two daily reads of `finality.weights.keys[]` |
| Votes | keys whose `voter` went false to true; `voters` for the total |
| Signatures | keys with `participation` above 0 at the day's last read; `latest_locked_index` for the lock |
| Full window | keys whose `blocks` span the whole `params.weightWindow` (owed from the node: `keys[].daysMined`; until then the line is left out, never estimated) |
| Rank 1 | `keys[]` sorted by `blocks`, the top row |
| Signing streak | owed from the node (`keys[].streakDays`); until then the line is left out |
| Shards | `/api/live proving.shards_paid_10m` summed over the day's reads, provers from `live_proofs.prover` |
A line whose field the node does not expose yet is left out of the post rather than estimated (the "no number a company server has to be trusted for" rule, reinvent.md section 6).
## Roles (the Discord lane, from the same fields)
| Role | Granted when | Revoked when |
|---|---|---|
| Voter | a message signed with the vote key names a key whose `voter` is true in `getFinalityWeights` | `voter` false at a daily read (a key under dust, or stripped: `strippedUntilDaa` above the DAA score) |
| Window | the key's blocks span the full `params.weightWindow` (`keys[].daysMined` owed; until then by hand from the ladder summary) | the key misses a day |
| Prover | a paid shard record names the key (`igneum_getProofRecords(block).paid[shard]`) | never (a paid record is a chain fact) |
The signed message is the proof of key ownership; the app does not sign it yet (owed: a "prove my key" button that signs a nonce with the vote key and copies it).

View file

@ -0,0 +1,67 @@
# Runbook: "first share within 10 minutes of download in 9 of 10 fresh Windows installs"
7 October 2026, miner-ui-5. The measurement behind the `/evidence` line in mission item 6 (docs/analysis/mission/mission.md
2.6, reinvent.md 3.1). Written for the shipper (ae892a8b0f78fe31c) to run at the Devnet 2 gate of each cut; the app
lane does not run it. Nothing here touches PC 1 or a live manifest.
## What is measured
Ten fresh Windows 11 installs, each timed from the download's first byte to the first accepted share or block, with
three timed steps between: the installer (download start to the app's first screen), the node (first screen to
`node.synced` true), the card (synced to the first `ACCEPTED block` line, or the first share once pool-0 exists). The
claim passes when 9 of 10 reach the first accepted block or share inside 600 s of the download's start.
## Where the numbers come from
| Step | Start mark | End mark | Read from |
|---|---|---|---|
| Download | the first byte of `Igneum-Miner-Setup-<v>.exe` (the fetch's own timestamp) | the file complete | the job script's clock |
| Install and first screen | the file complete | `api/state` answers with `phase` = welcome (the engine is up) | the app's URL file, then `api/state` |
| Setup | welcome | `setup_done` true (Continue, Make me an address, I have saved my key, Start mining: the playbook clicks or the job posts `api/setup`) | `api/state` |
| Node | `setup_done` | `node.synced` true and `node.state` synced | `api/state` |
| Card | synced | `ladder.first_block_at` set (the first `ACCEPTED block` line) or, with pool-0, the first `share_result` line | `api/state.ladder` |
| Total | download start | `ladder.first_block_at` (or the share) | both clocks, the box's and the VM's, read as UTC |
`api/state.ladder.first_synced_at` and `first_mining_at` carry the node and card marks from the engine's own clock; the
runbook takes those over the script's reads when both exist.
## The ten installs
- Ten fresh Windows 11 VMs (or ten fresh user profiles on the two PCs is NOT acceptable: a fresh profile shares the
machine's node data and its driver state). The fleet's Windows wave boxes are the candidates; PC 1 and PC 2 are not
(PC 1 is the project lead's desk, PC 2 holds a standing node).
- Each VM: the GPU driver installed, no Igneum folder under `%LOCALAPPDATA%`, the clock right (the clock card blocks
mining otherwise and the run is void, not a fail).
- The download is the public installer URL of the cut under test, through the dl host, never a scratch copy.
- The run is `node tools/build-job.mjs run --target <vm> --script relay/playbooks/first-share.ps1` with the playbook
recording the six marks as `RESULT` lines; the collect job brings `app/ladder.json` back.
## The playbook's RESULT lines
```
RESULT step=download start=<utc ms> end=<utc ms> bytes=<n>
RESULT step=install end=<utc ms>
RESULT step=setup end=<utc ms>
RESULT step=node end=<utc ms> synced_at=<ladder.first_synced_at>
RESULT step=card end=<utc ms> mining_at=<ladder.first_mining_at> first_block_at=<ladder.first_block_at> hash=<ladder.first_block_hash>
RESULT total_s=<end - download start> pass=<true|false>
```
A run without a `first_block_at` inside 1,800 s ends with `pass=false` and the step it stalled in. A Poisson miss is
a fail too: an 8 GB card at 100 GH/s has a 46 percent chance inside the first hour (reinvent 1.4), so the claim is
about the install and the node, and the card step is measured against the Poisson expectation from
`api/ladder.network.hashrate_hps` and the card's rate (`mining.hash_total`); a run whose card step passed the
expected gap by less than one gap is reported as "within expectation" beside the pass word.
## What is published
`/evidence` gets one table: ten rows (card model, driver, the four step times, the total, pass), the median of each
step, the pass count, the cut's version and the installer's sha256. The site lane owns the page; the shipper hands
it the table as a JSON file (`site/evidence/first-share-<version>.json`) with the same keys as the RESULT lines.
## Known holes
- Pool-0 is not built into the app yet (reinvent 3.5), so today the end mark is the first accepted block, not a share;
the claim's words say "first share or block" until the pool field ships.
- SmartScreen (reinvent 3.1) adds two clicks the playbook must make; the time they take counts in the install step.
- A VM without a GPU passthrough measures the node steps only and is reported as such, not as a pass.

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

98
docs/plans/miner-ui-5.md Normal file
View file

@ -0,0 +1,98 @@
# Igneum Miner UI 5: the ladder shown everywhere, the miner's first month
7 October 2026, 10:1x to 11:xx UK. Mission item 6 (docs/analysis/mission/mission.md 2.6; the brief is
docs/analysis/mission/reinvent.md 1, 2, 3.2 to 3.7 and 7). Branch `miner-ui-5` from the miner-ui-4 tip 5d9c55a2
(release-0.3.18, since renumbered 0.3.19), worktree `../igneum-wt-miner-ui-5`. Nothing published; the shipper cuts it.
The site lane (a4b202cabca2d95c0) owns `/live` and `/miners`; the node lane (a283f5f0d364ceef0) owns the RPC fields
listed in section 6.
## 1. What is built
| Part | Where | What |
|---|---|---|
| (a) The Poisson count-up | Overview, `#first-wait`; `View.poisson` | from the first job until the first block of this install: "A card like yours finds a block about every 1.6 hours on today's network. Chance so far: 31%." The gap is `/api/stats hashrate` over the fleet rate (`mining.hash_total`) at one block a second; the chance is `1 - e^(-elapsed / gap)` from the moment mining started this run. Over an 8-hour gap the pool line sits under it. The ring fills with the chance. Hover names the two fields and the formula. |
| (b) The block card | Overview, `#block-card`; `View.blockCard`; `src/ladder.rs` | raised by the engine on the first block of the install, every 100th, 1,000th and 10,000th, and the first block of a new card (`ladder.milestone`); in place, never a dialog; stays until dismissed (the key is stored, so a relaunch does not re-raise it). "Block 1,284,117 is yours." (the DAA score the node logged), the card and its rate and share, "4.88 IGN to 0xdd44…86E8 (72 producer points, 8 for signing, ramp 19%)", "rank 41 of 1,204 keys · day 23 of 30", the hash, Open in the explorer (`/block/<hash>`), Save the card, Copy the link. The hash joins the miner's `ACCEPTED block nonce=` line to the node's `PoW accepted <hash> … nonce` line by nonce (the node logs every block it validates). |
| Save the card | `drawCard` in app.js, `POST /api/card`, `src/card.rs`, `platform::reveal_file` | a 1200x630 PNG drawn on a canvas from the card's own words with the fonts the app ships (no network call): the mark, "Block N is yours", the card and rate, the IGN line, rank and day, the miner key and hash, the explorer URL. The miner's object, never the project's number: no network rate, no block count. The engine writes it under `<data root>/cards/` and reveals it in Finder or Explorer. `u00-block-card-1200x630.png` is the one the mock drew. |
| (c) Earnings in IGN first | Earnings, the money card; `View.earningsLines` | line 1 "7,680 IGN a day at your last hour's rate (4 blocks, 80.00 IGN each) · about 6,912 IGN expected at 100 MH/s" (measured first, the expectation beside it; the expectation alone before the first block); line 2 "2,592 blocks in 30 days, 207,360 IGN" from `getFinalityWeights keys[].blocks` at today's reward, with the shards; line 3 the balance (unchanged); line 4 the typed price: a £ per IGN the user types, kept in the page's storage, never fetched, "about £384 a day at £0.05 per IGN, your figure · no exchange lists IGN"; line 5 "your cards are 0.100% of the network · weight rank 41 of 1,204 keys · 23 of 30 days"; line 6 electricity unchanged plus "2,133 IGN per kWh"; the dev fee unchanged. Every number's `title` names its field. |
| The per-card line | Cards rows | an "IGN a day" cell per card from its rate, the network rate and the reward (hover: the fields) |
| (d) The ladder | Earnings `#ladder-card` (the rungs with their lines and hovers), Overview `#ladder-strip` (the seven chips and the current rung's line); `View.ladderRungs` | rungs: first block; your key has a vote; your signature is in checkpoint K; full window; rank by weight; signing streak; your card proved a shard. Every threshold is read from the node's `params` (`dust`, `weightWindow`, `presenceWindow`), never a constant: the devnet reads "5 blocks to a vote" and "2 of 2 hours", mainnet "100 blocks" and "30 of 30 days". The words, in copy law: "Your key has a vote." "Your signature is in checkpoint 184,220." "30 of 30 days. Full weight." "Rank 41 of 1,204 keys." "Signing 8 of 80 points, 41 days unbroken." "Your card proved shard 3 of block 1,284,117." |
| The chain facts | `GET /api/ladder`, `src/chainfacts.rs` | the node's `igneum_getFinalityWeights` on the EVM port first (OWED on the node, section 6), else the observer's `/api/live finality.weights` (the same reply relayed, keys cut to 64, ids as 8 hex) plus `/api/stats`; this machine's keys ranked by `blocks`; cached 10 s; the reply names its source and the dashboard says it ("through the observer" until the node answers). |
| The machine's record | `src/ladder.rs`, `<app dir>/ladder.json`, `api/state.ladder` | first block (time, card, hash, DAA), blocks per UTC day (the days-mined ring), the checkpoints signed and which locked (`VOTE index=` and `LOCK checkpoint` lines), the signing streak (a gap over the presence window ends it), the paid shards (`src/prover.rs`), the milestone, the first-hour marks (node synced, card mining). |
| (e) The public profile opt-in | Settings "Make my page public"; `settings.profile_public`, `api/settings {profile_public}` | off by default; the words say what becomes public (key ids, blocks, rank, presence, card model). The site reads the switch through the app's existing report path (owed to the site lane; today the switch is stored and shown, nothing leaves the machine). |
| (f) The first-hour timeline | Earnings "Your first hour"; `View.timeline` | Installed (`settings.installed_at`), App started, Node synced (`ladder.first_synced_at`), Card mining (`ladder.first_mining_at`), First block, First payout (the coinbase of the first block), each as "+N min" from the install. |
| Prove | `#shard-card` | "Your card proved shard 3 of block 1,284,117." with the IGN, on the newest paid record |
| Activity | `EVENT_MAP` | "Your first block, found by RTX 4070", "Block 1,000 of this machine, found by …", "First block on …" |
## 2. The chain scene (EMBER 02, taken from the site lane)
the project lead's renderer pack (11:3x UK): `ui/live-dag.js` (sha256 e78f2d38…, IgneumDag 2.0.1: the site lane's real-data options, maxLanes 7 with the own key first and an OTHERS lane, minNode 9.5, merge hairlines within two lanes, provenGlow; 2.0.0 was 582f2e73…) and `ui/proof-core.js` (c2a094a6…,
IgneumProof 2.0.0) are byte-identical copies of the site-ui-4 files; the engine serves `proof-core.js` beside
`live-dag.js`. The Overview card mounts the compact mode (`compact: true, window: 60, poll: false`); Inspect, or a
click on a block, opens the full scene under it with the block inspector in the site's words (Proof in progress /
Proof complete / Checkpoint locked / Block observed; Proof shards, Inclusion, Miner key, Blue score, DAA score,
Parent links, Header time, Checkpoint, Finality; the note on separate states), the filter tabs (All blocks, Selected
chain, Your blocks), the window stepper, Follow and Pause. One owner of the feed: the page reads the engine's
`api/live?window=120` every 2 s and pushes the reply into both scenes; `mine` matches every vote key id of this
machine's cards (the 8 hex the feed names as `b.miner`), never the payout address. `--included` and `--excluded` are on
`:root` for both themes. The fallback strip (this machine's own blocks) draws after three failed reads. The legend
reads "Your blocks" as in the reference.
Caption fact from the site lane: the observer's per-block shard states read "planned" for every chain block in the window while its paid counters move (10:08 UK), so the live scene shows no proven block until the observer's read is fixed (the fleet lane has it); the mock's feed shows the proven state.
## 3. Gate results
| Gate | Result |
|---|---|
| A Devnet 2 key walks every rung on screen in a view test | `view.test.mjs` "a Devnet 2 key walks every rung on screen": key 6ad0e117 under the finality params the devnet node reports (`api/live params`, 7 Oct 2026: dust 5, weightWindow 7,200, presenceWindow 20; Devnet 2's override file sets none of them, so it runs the same values) goes nothing → first block → vote → signature in checkpoint 8,496 → 2 of 2 hours → rank 3 of 4 → 15 min unbroken → shard 3 of block 1,284,117; every rung's `field` matches `getFinalityWeights|getFinalityCheckpoints|igneum_getProofRecords|/api/stats|this app`. The mainnet-sized walk (100 blocks, 30 days, the observer's cap of 64) is the next test. |
| Every number names its RPC field on hover | `View.FIELDS` (23 sentences) in every `title`; the rungs, the Earnings lines, the block card lines, the Cards cell, the count-up |
| The PNG opens; no network call is made to make it | `u00-block-card-1200x630.png` drawn by the mock page through Playwright's chromium; the page's only calls while drawing are `document.fonts.load` of the app's own woff2 files |
| "First share within 10 minutes of download in 9 of 10 fresh Windows installs" | written as a runbook for the shipper, not run: `docs/plans/miner-ui-5-first-share-runbook.md` |
| Tests | UI 47 (42 before; the five new ones); the app crate on the box 190 + 27 + 8 (ladder 10, chainfacts 4, card 3 among them) |
## 4. Captures (`docs/plans/miner-ui-5-shots/`, the ui-mock's scenarios firstwait, firstblock, ladder)
`u00-block-card-1200x630.png` the saved card; `u01-overview-firstwait-dark-1440` the count-up and the strip; `u02`/`u03`
the first block's card, dark and light; `u04`/`u14` the full scene with a block in the inspector, dark and light;
`u05`/`u06` Earnings with the ladder and the timeline open; `u07` the shard card; `u08` the Cards row with IGN a day;
`u09` Settings with the profile switch; `u10` to `u13` the Overview and Earnings at 390, both themes. Captured through
Playwright's own chromium (never Google Chrome.app) against `tools/ui-mock/server.mjs 4319`.
## 5. Owed by this lane, and what the site lane and the Discord lane take
| What | Who |
|---|---|
| The Cards setup screen's "about N blocks a day" from the priors file (reinvent 3.2, Cards rows) | this lane, once the priors ship in the signed manifest (the file lives on the site today) |
| The QR of the explorer link on the saved card (reinvent 3.3) | this lane; the URL is printed in full instead |
| The opt-in reaching the site (the address page's noindex lifted for a key whose app switched it on) | the site lane and this lane together: the switch rides the app's existing report upload as `profile_public` |
| `/live` weight leaderboard and `/miners` census with the same words | the site lane; the field list and the rung words were sent to it (section 7) |
| `#first-blocks` posts and the Voter and Window roles | the Discord lane; the shapes are in `docs/community/discord/ladder.md`, nothing posted |
| Sound on a block | not built (verdict table: watch) |
## 6. RPC fields the node does not expose yet (sent to the node lane)
| Field | Why the app needs it | What the app does meanwhile |
|---|---|---|
| `igneum_getFinalityWeights` on the EVM JSON-RPC port (the wRPC `getFinalityWeights` reply as is) | the engine carries no wRPC client; today the facts come through the observer's relay, which cuts the table to 64 keys and so cannot rank a key below 64 | reads `/api/live finality.weights` and says "through the observer" on hover; "Below the top 64 keys" when the key is not listed |
| `igneum_getFinalityCheckpoints` the same way | the locked index and the certificate list from this machine's node | `/api/stats finality.latest_locked_index` |
| `getFinalityCheckpoints checkpoints[].signers` (the key hashes in the certificate bitmap), or a per-key `lastSignedLockedIndex` | "Your signature is in checkpoint K" as a chain fact | the app's own `VOTE index=` lines, read as locked once a `LOCK checkpoint` line reaches them; the hover says so |
| `getFinalityWeights keys[].daysMined` (distinct days with a blue block in the window) and `keys[].firstBlockDaa` | "30 of 30 days" as a chain fact; the Window role; the `/live` days-mined column | the app's own day ring (`ladder.json`), which only counts what this install saw |
| `getFinalityWeights keys[].streakDays` or `keys[].lastMissedDaa` | the signing streak as a chain fact | the app's own unbroken-vote reading; `participation` is shown beside it |
| `getFinalityWeights keys[].rank` or a `getFinalityKey {keyHash}` reply with blocks, voter, participation, rank, daysMined, streak | one call per machine instead of the whole table | the table sorted on the client |
| `igneum_getProofRecords` by key (the paid shards of a key over a range) | "your card proved shard N of block M" for records this install did not watch | `src/prover.rs` reports what it submitted and saw paid |
## 7. Sent to the site lane (so `/live` and `/miners` match the app word for word)
The rung names and lines of section 1 (d); the fields: `getFinalityWeights params.dust / weightWindow / presenceWindow`,
`keys[].blocks / voter / participation / strippedUntilDaa / pubkey`, `totalWeight`, `activeWeight`, `voters`,
`checkpointIndex`; `getFinalityCheckpoints latestLockedIndex / nextIndex / finalityActive`; `/api/stats hashrate`,
`block_reward.miner_ign`, `block_reward.ramp_factor`; the leaderboard column order (rank, key id, blocks in window,
days mined, presence, last block) with "weight, never hashrate: a card that arrived today sits at the bottom".
## 8. Copy added (the copy law: no em dashes, no two-beat antithesis, no aphorisms; every line names a number or an action)
"Waiting for your first block." "A card like yours finds a block about every 1.6 hours on today's network. Chance so
far: 31%." "Above an 8-hour gap a pool pays by the share, every minute. The pool setting arrives with pool-0." "Block
1,284,117 is yours." "Open in the explorer" "Save the card" "Copy the link" "Your key has a vote." "61 of 100 blocks to
a vote." "Your signature is in checkpoint 184,220." "30 of 30 days. Full weight." "Rank 41 of 1,204 keys." "Below the
top 64 keys." "Signing 8 of 80 points, 41 days unbroken." "Your card proved shard 3 of block 1,284,117." "Proving is
off." "Type a price" "Use it" "Make my page public" "Your first hour" "The ladder".

View file

@ -18,6 +18,11 @@
// biglog 12,000 log lines with errors and program swaps (virtualised list)
// fresh setup not done (welcome, cards, address screens)
// job a remote job running
// firstwait miner-ui-5: mining, no block yet (the Poisson count-up), the ladder's first rung
// firstblock miner-ui-5: the first block a minute ago (the block card), the key under the dust line
// ladder miner-ui-5: every rung walked (vote, signature, full window, rank 3, streak, a paid shard)
// GET /api/ladder answers the chain facts in src/chainfacts.rs's shape (the devnet's params, this key ranked);
// POST /api/card accepts the block card's PNG and answers a path.
// The log grows by a few lines a second in every scenario, so follow and scroll-to-bottom can be exercised.
import http from 'node:http';
@ -107,10 +112,34 @@ function liveNow(windowS) {
for (let s = from; s <= t - 2; s++) if ((s - LIVE_T0) % 30 === 0) checkpoints.push({ index: Math.floor((s - LIVE_T0) / 30), hash: 'c' + s.toString(16), blue_score: 194000 + (s - LIVE_T0), daa: 198000 + (s - LIVE_T0), state: s < t - 10 ? 'locked' : 'pending', fraction_total: s < t - 10 ? 0.71 : 0.44 });
return { ok: true, now: t, state: { stale: false, age_s: 1, height: 198000 + (t - LIVE_T0) }, blocks, finality: { checkpoints } };
}
// miner-ui-5: this machine's ladder record (state.ladder, src/ladder.rs) per scenario
function ladderNow(t) {
const base = { first_block_at: 0, first_block_card: '', first_block_hash: '', first_block_daa: 0, days_mined_30: 0, blocks_30d: 0, blocks_today: 0, days: [], cards_first: {}, blocks: [], votes_signed: 0, last_vote_index: 0, last_vote_at: 0, last_signed_locked: 0, signed_locked_count: 0, streak_s: 0, shards: [], milestone: null, first_synced_at: t0 - 2800, first_mining_at: t0 - 2700 };
const hash = '9f3a7c1e5d2b4a6f8e0c1d3b5a7f9e2c4d6b8a0f1e3c5d7b9a2f4e6c8d0b1a3c21e';
if (scenario === 'firstblock') return { ...base, first_block_at: t - 70, first_block_card: recState.mining.cards[0].key, first_block_hash: hash, first_block_daa: 266454, days_mined_30: 1, blocks_30d: 1, blocks_today: 1, blocks: [{ hash, daa: 266454, nonce: '267dd27200a91c80', at: t - 70, card: recState.mining.cards[0].key, card_name: recState.mining.cards[0].name, count: 1 }], votes_signed: 2, last_vote_index: 8496, last_vote_at: t - 20, milestone: { kind: 'first', count: 1, card: recState.mining.cards[0].key, card_name: recState.mining.cards[0].name, hash, daa: 266454, at: t - 70 } };
if (scenario === 'ladder') return { ...base, first_block_at: t - 7300, first_block_card: recState.mining.cards[0].key, first_block_hash: hash, first_block_daa: 259200, days_mined_30: 1, blocks_30d: 61, blocks_today: 61, votes_signed: 240, last_vote_index: 8495, last_vote_at: t - 12, last_signed_locked: 8495, signed_locked_count: 238, streak_s: 7100, shards: [{ block: 160390, shard: 3, wei: '1150000000000000000', at: t - 400 }], milestone: { kind: 'hundred', count: 100, card: recState.mining.cards[0].key, card_name: recState.mining.cards[0].name, hash, daa: 266400, at: t - 50 } };
return base;
}
// miner-ui-5: GET /api/ladder, in src/chainfacts.rs's shape: the devnet's finality params (api/live, 7 Oct 2026),
// four keys relayed through the observer, this machine's key 8fafda27 ranked per scenario
function ladderReply(t) {
const mine = scenario === 'firstblock' ? { id: '8fafda27', blocks: 1, voter: false, participation: 0, stripped_until_daa: 0, revealed: false, rank: 4 }
: scenario === 'ladder' ? { id: '8fafda27', blocks: 61, voter: true, participation: 1, stripped_until_daa: 0, revealed: true, rank: 3 }
: scenario === 'firstwait' ? null : { id: '8fafda27', blocks: 251, voter: true, participation: 1, stripped_until_daa: 0, revealed: true, rank: 2 };
return { ok: true, source: 'observer', read_at: t, cached_age_s: 0, rpc: { weights: 'getFinalityWeights', checkpoints: 'getFinalityCheckpoints', stats: '/api/stats', relay: 'igneum.network/api/live (the observer\'s copy of the node\'s reply)' },
params: { dust: 5, weight_window: 7200, presence_window: 20, checkpoint_interval: 30, min_daa: 7200 }, checkpoint_index: 8495, daa_score: 266454, latest_locked_index: 8495, next_index: 8496, finality_active: true,
total_weight: 6000, active_weight: 5900.5, voters: 14, keys_listed: 4, keys_cap: 64,
network: { hashrate_hps: 764944722, miner_ign_per_block: 4.88044084, ign_per_block: 6.10055105, ramp_factor: 0.192519, daa: 266454, blocks_per_day_measured: 92280, bps: 1, stale: false },
mine: mine ? [mine] : [], best: mine };
}
function stateNow() {
const s = clone(recState);
const t = now();
s.now = t;
s.ladder = ladderNow(t);
s.installed_at = t0 - 3000;
if (scenario === 'firstwait') { s.mining.accepted_total = 0; s.mining.accepted_session = 0; s.mining.cards[0].accepted = 0; s.mining.found = []; s.finality = { ...s.finality, last_lock: 0, votes: 0 }; }
if (scenario === 'firstblock') { s.mining.accepted_total = 1; s.mining.accepted_session = 1; s.mining.cards[0].accepted = 1; }
s.uptime_s = recState.uptime_s + Math.floor(t - t0);
s.mining.found = recState.mining.found.map(x => x + shift).filter(x => t - x < 3600);
s.events = recState.events.map(e => ({ ...e, t: e.t + shift }));
@ -124,8 +153,11 @@ function stateNow() {
// a live-looking hash rate
const wob = Math.sin((t - t0) / 7) * 1.4;
s.mining.hash_total = +(16.9 + wob).toFixed(2); s.mining.cards[0].hash_now = s.mining.hash_total;
// a fresh block every ~9 s on average
const extra = []; for (let k = t0; k < t; k += 9.3) extra.push(k + 0.4);
// a fresh block every ~9 s on average (none before the first block)
const extra = []; if (scenario !== 'firstwait' && scenario !== 'firstblock') for (let k = t0; k < t; k += 9.3) extra.push(k + 0.4);
if (scenario === 'firstwait') { s.mining.found = []; s.mining.accepted_total = 0; s.mining.accepted_session = 0; s.mining.cards[0].accepted = 0; s.events = s.events.filter(e => e.kind !== 'block'); }
if (scenario === 'firstblock') { s.mining.found = [t - 70]; s.events = [{ kind: 'block', t: t - 70, text: 'first block: found by ' + s.mining.cards[0].name + ' (block card)' }, { kind: 'block', t: t - 70, text: 'block accepted by the node (' + s.mining.cards[0].name + ')' }].concat(s.events.filter(e => e.kind !== 'block')).slice(0, 40); }
if (scenario === 'ladder') { s.settings.prove = true; s.proving = { ...s.proving, enabled: true, available: true, status: 'idle', paid: 1, paid_wei: '1150000000000000000', last_paid: 'block 160390 shard 3' }; s.events = [{ kind: 'proving', t: t - 400, text: 'block 160390 shard 3 paid 1.15 IGN' }, { kind: 'block', t: t - 50, text: 'block 100 of this machine: found by ' + s.mining.cards[0].name + ' (block card)' }].concat(s.events).slice(0, 40); }
s.mining.found = s.mining.found.concat(extra).filter(x => t - x < 3600).sort((a, b) => a - b);
s.mining.accepted_session += extra.length; s.mining.accepted_total += extra.length; s.mining.cards[0].accepted += extra.length;
if (extra.length) s.events = [{ kind: 'block', t: extra[extra.length - 1], text: 'block accepted by the node (Apple M5 Max)' }].concat(s.events).slice(0, 40);
@ -193,6 +225,7 @@ http.createServer((req, res) => {
}
return json(res, liveNow(parseInt(u.searchParams.get('window') || '120', 10)));
}
if (rest === '/api/ladder' && req.method === 'GET') return json(res, ladderReply(now()));
if (rest === '/api/log' && req.method === 'GET') {
const after = parseInt(u.searchParams.get('after') || '0', 10), limit = Math.min(20000, parseInt(u.searchParams.get('limit') || '400', 10));
let out = log.filter(l => l.seq > after); if (out.length > limit) out = out.slice(out.length - limit);
@ -203,6 +236,7 @@ http.createServer((req, res) => {
posts.push({ t: now(), path: rest, body: body.slice(0, 300) }); console.log('POST', rest, body.slice(0, 200));
if (rest === '/api/pause') paused = true; if (rest === '/api/resume') paused = false;
if (rest === '/api/key/reveal') return json(res, { ok: true, private_key: '0x' + 'ab'.repeat(32) });
if (rest === '/api/card') { let bytes = 0; try { bytes = Buffer.from((JSON.parse(body).png || '').split(',')[1] || '', 'base64').length; } catch (e) { } return json(res, { ok: true, path: '/Users/you/Library/Application Support/Igneum/cards/igneum-block-block-1-' + Math.floor(now()) + '.png', bytes }); }
if (rest === '/api/setup') return json(res, { ok: true, address: '0xdd442fcbb964a3afdc90d49b408e8dd296fa86e8', display: '0xdd442fCbb964A3aFDc90D49B408e8DD296FA86E8', private_key: '0x' + 'cd'.repeat(32), wallet_file: '/Users/you/Library/Application Support/Igneum/app/wallet.json' });
json(res, { ok: true });
});