Merge commit 'e22d2425' into release-0.3.19
80
app/igneum-app/src/card.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
263
app/igneum-app/src/chainfacts.rs
Normal 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(""), "");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
514
app/igneum-app/src/ladder.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)}}
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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">×</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">−</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 · 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’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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
72
app/igneum-app/ui/proof-core.js
Normal 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);
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
78
docs/community/discord/ladder.md
Normal 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).
|
||||
67
docs/plans/miner-ui-5-first-share-runbook.md
Normal 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.
|
||||
BIN
docs/plans/miner-ui-5-shots/u00-block-card-1200x630.png
Normal file
|
After Width: | Height: | Size: 414 KiB |
BIN
docs/plans/miner-ui-5-shots/u01-overview-firstwait-dark-1440.png
Normal file
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 327 KiB |
BIN
docs/plans/miner-ui-5-shots/u04-overview-inspect-dark-1440.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
docs/plans/miner-ui-5-shots/u05-earnings-ladder-dark-1440.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
docs/plans/miner-ui-5-shots/u06-earnings-ladder-light-1440.png
Normal file
|
After Width: | Height: | Size: 132 KiB |
BIN
docs/plans/miner-ui-5-shots/u07-prove-shard-dark-1440.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
docs/plans/miner-ui-5-shots/u08-cards-ign-dark-1440.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
docs/plans/miner-ui-5-shots/u09-settings-profile-light-1440.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
docs/plans/miner-ui-5-shots/u10-overview-firstblock-dark-390.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
docs/plans/miner-ui-5-shots/u11-overview-firstwait-light-390.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
docs/plans/miner-ui-5-shots/u12-earnings-ladder-dark-390.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
docs/plans/miner-ui-5-shots/u13-earnings-ladder-light-390.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/plans/miner-ui-5-shots/u14-overview-inspect-light-1440.png
Normal file
|
After Width: | Height: | Size: 320 KiB |
98
docs/plans/miner-ui-5.md
Normal 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".
|
||||
|
|
@ -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 });
|
||||
});
|
||||
|
|
|
|||