Compare commits
150 commits
master
...
release-0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b54bfc63c | ||
|
|
69682eeecc | ||
|
|
16bd5f7347 | ||
|
|
4570f92ab9 | ||
|
|
927895a69d | ||
|
|
e22d24253a | ||
|
|
49db28fb9c | ||
|
|
f508274fe2 | ||
|
|
32793b686a | ||
|
|
959c4a5fa9 | ||
|
|
0d05308e8d | ||
|
|
644461d7e2 | ||
|
|
032e1f8717 | ||
|
|
754d22c6ea | ||
|
|
0f6b4650b6 | ||
|
|
8084d88d23 | ||
|
|
a7a9e5c231 | ||
|
|
017553b8c5 | ||
|
|
e619bc96c3 | ||
|
|
447a7d82fc | ||
|
|
3fa25b2d23 | ||
|
|
4b597d73ae | ||
|
|
e602e7cda6 | ||
|
|
4ec47b6b62 | ||
|
|
faa7955101 | ||
|
|
53a1b1c5e5 | ||
|
|
8d33d2722d | ||
|
|
110960376c | ||
|
|
3307351d79 | ||
|
|
5012c1ddc8 | ||
|
|
f59fb2d05b | ||
|
|
00e35af4bc | ||
|
|
029fd440d1 | ||
|
|
cb272df466 | ||
|
|
4e1dc54973 | ||
|
|
40f4c1d860 | ||
|
|
e5a55d1b05 | ||
|
|
d283472386 | ||
|
|
068f5889df | ||
|
|
f9816b1c18 | ||
|
|
0e6c3b42ec | ||
|
|
45fd85dcdb | ||
|
|
3d6af04893 | ||
|
|
dcc758264d | ||
|
|
01c91f079d | ||
|
|
29234803db | ||
|
|
302d6e7055 | ||
|
|
59bbf46689 | ||
|
|
4f6cfbfba7 | ||
|
|
98c1f05eff | ||
|
|
172696bb26 | ||
|
|
4793cca547 | ||
|
|
24adda1025 | ||
|
|
6dd3c3686b | ||
|
|
5e3e6a1664 | ||
|
|
214333d5e6 | ||
|
|
116e767c0d | ||
|
|
d695948d2e | ||
|
|
a9c3167802 | ||
|
|
d969588aac | ||
|
|
011443a76f | ||
|
|
a621a04b29 | ||
|
|
93c530d0a1 | ||
|
|
d0c98db2fa | ||
|
|
6084a7ea64 | ||
|
|
708b1e0fc8 | ||
|
|
d33fa30f89 | ||
|
|
1e8bcf7cb1 | ||
|
|
b3cd2d21f4 | ||
|
|
c4d29c58fd | ||
|
|
9c9a09ff09 | ||
|
|
ea76eac0f8 | ||
|
|
30511ab700 | ||
|
|
60e78a56ba | ||
|
|
10112d1df7 | ||
|
|
9601749fb2 | ||
|
|
3559537130 | ||
|
|
e976b344f3 | ||
|
|
ea7c30d799 | ||
|
|
c07d259323 | ||
|
|
de058a4af5 | ||
|
|
0969bedb2e | ||
|
|
e49c7a058a | ||
|
|
3ef7f901c6 | ||
|
|
fddb02d016 | ||
|
|
359dd11d00 | ||
|
|
ebe27aae69 | ||
|
|
3be454f128 | ||
|
|
93b05c9b7b | ||
|
|
54cc0bd6aa | ||
|
|
12f4fdbde3 | ||
|
|
f35816ddfb | ||
|
|
ff320d9fe3 | ||
|
|
5baaa32273 | ||
|
|
3fd96e4882 | ||
|
|
547b3323d2 | ||
|
|
3dd010bca8 | ||
|
|
f242646a01 | ||
|
|
91c3ed747d | ||
|
|
62736cecb5 | ||
|
|
19b802a17b | ||
|
|
418801fac2 | ||
|
|
0aef24f747 | ||
|
|
7ee76db53c | ||
|
|
4bf858f3db | ||
|
|
8321127385 | ||
|
|
f0660d59c3 | ||
|
|
6f6d058c9f | ||
|
|
9201a07faf | ||
|
|
7996240f6f | ||
|
|
2c2859253d | ||
|
|
e8244a7853 | ||
|
|
17e1eb4d12 | ||
|
|
f7d5db67ad | ||
|
|
98b686059a | ||
|
|
54bcdbe510 | ||
|
|
64e70bb27d | ||
|
|
8aa8f0a048 | ||
|
|
05a2d0599a | ||
|
|
b371bf2bf4 | ||
|
|
0ac8d097b2 | ||
|
|
5b4cb56a27 | ||
|
|
fa20b72fea | ||
|
|
4af9ab4d0a | ||
|
|
409cb0f76c | ||
|
|
7a69c03135 | ||
|
|
7ce5965e46 | ||
|
|
18b154de9c | ||
|
|
7943f83c17 | ||
|
|
4f6b7810d5 | ||
|
|
fc94277775 | ||
|
|
02627f3d9f | ||
|
|
441d1eaf88 | ||
|
|
53d99b17f9 | ||
|
|
3cc1b9ef11 | ||
|
|
0b48c028f3 | ||
|
|
95707125c5 | ||
|
|
f57c5293bc | ||
|
|
3d0a6458da | ||
|
|
2b244d732e | ||
|
|
0659fab5f2 | ||
|
|
0477f8857d | ||
|
|
6e9bd00dbb | ||
|
|
98bdeeb88b | ||
|
|
5143cd2ed6 | ||
|
|
9a45ea3785 | ||
|
|
75a6364384 | ||
|
|
bd3fb7a809 | ||
|
|
ecfffa019a | ||
|
|
14dc093549 |
4
.gitignore
vendored
|
|
@ -32,6 +32,10 @@ vendor/igneum-node-ship/
|
||||||
brand/trademark/pbip-pack/
|
brand/trademark/pbip-pack/
|
||||||
brand/trademark/*.zip
|
brand/trademark/*.zip
|
||||||
|
|
||||||
|
# cargo target dirs of the shipper's local test runs and the build box's fetched artefacts (6 October 2026: a stray add -A staged 450 of them)
|
||||||
|
app/igneum-app/target-tests/
|
||||||
|
proving/igneum-prove/target-remote/
|
||||||
|
**/target-remote/
|
||||||
# the Discord bot and reddit bot dry-run renders
|
# the Discord bot and reddit bot dry-run renders
|
||||||
tools/community/out/
|
tools/community/out/
|
||||||
# the GPU workers built on igneum-build-1 (tools/workers-remote.sh); binaries, never committed
|
# the GPU workers built on igneum-build-1 (tools/workers-remote.sh); binaries, never committed
|
||||||
|
|
|
||||||
2
app/igneum-app/Cargo.lock
generated
|
|
@ -219,7 +219,7 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "igneum-app"
|
name = "igneum-app"
|
||||||
version = "0.3.14"
|
version = "0.3.19"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ed25519-dalek",
|
"ed25519-dalek",
|
||||||
"getrandom",
|
"getrandom",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "igneum-app"
|
name = "igneum-app"
|
||||||
version = "0.3.14"
|
version = "0.3.19"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Igneum Miner engine: supervises the node, the miner and the GPU workers, and serves the dashboard on 127.0.0.1"
|
description = "Igneum Miner engine: supervises the node, the miner and the GPU workers, and serves the dashboard on 127.0.0.1"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@
|
||||||
1 ICON "igneum.ico"
|
1 ICON "igneum.ico"
|
||||||
|
|
||||||
1 VERSIONINFO
|
1 VERSIONINFO
|
||||||
FILEVERSION 0,3,14,0
|
FILEVERSION 0,3,19,0
|
||||||
PRODUCTVERSION 0,3,14,0
|
PRODUCTVERSION 0,3,19,0
|
||||||
FILEFLAGSMASK 0x3fL
|
FILEFLAGSMASK 0x3fL
|
||||||
FILEFLAGS 0x0L
|
FILEFLAGS 0x0L
|
||||||
FILEOS VOS_NT_WINDOWS32
|
FILEOS VOS_NT_WINDOWS32
|
||||||
|
|
@ -20,12 +20,12 @@ BEGIN
|
||||||
BEGIN
|
BEGIN
|
||||||
VALUE "CompanyName", "Igneum"
|
VALUE "CompanyName", "Igneum"
|
||||||
VALUE "FileDescription", "Igneum Miner engine"
|
VALUE "FileDescription", "Igneum Miner engine"
|
||||||
VALUE "FileVersion", "0.3.14"
|
VALUE "FileVersion", "0.3.19"
|
||||||
VALUE "InternalName", "igneum-app"
|
VALUE "InternalName", "igneum-app"
|
||||||
VALUE "LegalCopyright", "Igneum contributors"
|
VALUE "LegalCopyright", "Igneum contributors"
|
||||||
VALUE "OriginalFilename", "igneum-app.exe"
|
VALUE "OriginalFilename", "igneum-app.exe"
|
||||||
VALUE "ProductName", "Igneum Miner"
|
VALUE "ProductName", "Igneum Miner"
|
||||||
VALUE "ProductVersion", "0.3.14"
|
VALUE "ProductVersion", "0.3.19"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|
|
||||||
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(""), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,15 @@ pub struct CardPref {
|
||||||
pub sweep_class: String,
|
pub sweep_class: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sweep_source: String,
|
pub sweep_source: String,
|
||||||
|
/// Miner UI 4 (6 October 2026): this card's goal (efficiency | balanced | rate); empty = the global tune_goal
|
||||||
|
#[serde(default)]
|
||||||
|
pub tune_goal: String,
|
||||||
|
/// the untuned point the last FULL plan measured first (its step 0), kept across confirm plans so the row can
|
||||||
|
/// read "saves 84 W, 0.15% of rate"; 0 = never measured
|
||||||
|
#[serde(default)]
|
||||||
|
pub sweep_before_watts: f64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sweep_before_mhs: f64,
|
||||||
/// Ember 2: the memory clock the last tune chose (0 = the driver's default)
|
/// Ember 2: the memory clock the last tune chose (0 = the driver's default)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sweep_mem_mhz: u32,
|
pub sweep_mem_mhz: u32,
|
||||||
|
|
@ -120,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.
|
/// on when the machine can prove, never switching an explicit on back off). Older installs apply it at their next start.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub prove_default_applied: bool,
|
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 {
|
fn one() -> u32 {
|
||||||
|
|
@ -134,7 +147,7 @@ fn yes() -> bool {
|
||||||
|
|
||||||
impl Default for Settings {
|
impl Default for Settings {
|
||||||
fn default() -> 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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -357,8 +370,9 @@ impl Runtime {
|
||||||
let p2p_port = env("IGNEUM_APP_P2P_PORT").and_then(|v| v.parse().ok()).unwrap_or(26611);
|
let p2p_port = env("IGNEUM_APP_P2P_PORT").and_then(|v| v.parse().ok()).unwrap_or(26611);
|
||||||
let peers = match std::env::var("IGNEUM_APP_PEERS") {
|
let peers = match std::env::var("IGNEUM_APP_PEERS") {
|
||||||
Ok(v) => v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
|
Ok(v) => v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect(),
|
||||||
// the public seed node first, then the project lead's Mac on the house LAN (devnet only)
|
// the public seed node first, then the build box's hand nodes (main's decision (b), 6 October 2026, 19:1x UTC:
|
||||||
Err(_) if network == "devnet" => vec!["188.245.5.161:26611".to_string(), "192.168.68.64:26611".to_string()],
|
// the hands move off the project lead's Mac to igneum-build-1), then the project lead's Mac on the house LAN (devnet only)
|
||||||
|
Err(_) if network == "devnet" => vec!["188.245.5.161:26611".to_string(), "188.40.146.49:26611".to_string(), "192.168.68.64:26611".to_string()],
|
||||||
Err(_) => vec![],
|
Err(_) => vec![],
|
||||||
};
|
};
|
||||||
let unsynced_mining = env("IGNEUM_APP_UNSYNCED").map(|v| v == "1").unwrap_or(false);
|
let unsynced_mining = env("IGNEUM_APP_UNSYNCED").map(|v| v == "1").unwrap_or(false);
|
||||||
|
|
|
||||||
|
|
@ -906,6 +906,33 @@ pub fn tuned_line(mhs: f64, watts: f64, eff: f64) -> String {
|
||||||
format!("Tuned: {mhs:.1} MH/s at {watts:.0} W ({eff:.3} MH/W)")
|
format!("Tuned: {mhs:.1} MH/s at {watts:.0} W ({eff:.3} MH/W)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Miner UI 4 (6 October 2026): a card's own goal when set, else the global one.
|
||||||
|
pub fn goal_for(card_goal: &str, global: &str) -> Goal {
|
||||||
|
Goal::parse(if card_goal.trim().is_empty() { global } else { card_goal })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (D) May a step be SET on this card? Measure-only (no control: Apple, NVIDIA without Power control, an AMD card
|
||||||
|
/// whose probe gave no tune line) sets nothing: the run notices the missing acknowledgement and measures. An AMD
|
||||||
|
/// card also needs its ADLX ordinal. 6 October 2026, PC 1 17:57Z: a measure-only 9070 XT was sent a set, refused.
|
||||||
|
pub fn may_set(vendor: &str, tune_control: bool, amd_ordinal: i64) -> bool {
|
||||||
|
match vendor {
|
||||||
|
"nvidia" => tune_control,
|
||||||
|
"amd" => tune_control && amd_ordinal >= 0,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (E) The row's words while a back-off from a failed tune holds: when the retry comes and why it stopped.
|
||||||
|
pub fn retry_note(remaining_s: u64, last_note: &str) -> String {
|
||||||
|
let mins = (remaining_s + 59) / 60;
|
||||||
|
let why = last_note.trim_start_matches("tuning stopped: ").trim_start_matches("tuning: ").trim();
|
||||||
|
if why.is_empty() {
|
||||||
|
format!("tuning: retry in {mins} min")
|
||||||
|
} else {
|
||||||
|
format!("tuning: retry in {mins} min ({why})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The row's line for a baseline (measure-only) result: the card was measured as it runs, nothing was set.
|
/// The row's line for a baseline (measure-only) result: the card was measured as it runs, nothing was set.
|
||||||
/// (6 October 2026: PC 1's rows read "Tuned: 114.2 MH/s at 305 W" for a baseline, which is not a tune.)
|
/// (6 October 2026: PC 1's rows read "Tuned: 114.2 MH/s at 305 W" for a baseline, which is not a tune.)
|
||||||
pub fn measured_line(mhs: f64, watts: f64, eff: f64) -> String {
|
pub fn measured_line(mhs: f64, watts: f64, eff: f64) -> String {
|
||||||
|
|
@ -1007,6 +1034,26 @@ mod tests {
|
||||||
assert!(Run::applied(&step, Readback { limit_w: 90.0, acked: false }, 100.0));
|
assert!(Run::applied(&step, Readback { limit_w: 90.0, acked: false }, 100.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_card_goal_overrides_the_global_goal_and_empty_follows_it() {
|
||||||
|
assert_eq!(goal_for("", "balanced"), Goal::Balanced);
|
||||||
|
assert_eq!(goal_for("rate", "balanced"), Goal::MaxRate);
|
||||||
|
assert_eq!(goal_for("efficiency", "rate"), Goal::Efficiency);
|
||||||
|
assert_eq!(goal_for(" ", "efficiency"), Goal::Efficiency);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_measure_only_card_is_never_set_and_a_back_off_says_when_the_retry_comes() {
|
||||||
|
assert!(!may_set("amd", false, 1), "the probe gave no tune line: measure only");
|
||||||
|
assert!(may_set("amd", true, 1));
|
||||||
|
assert!(!may_set("amd", true, -1), "no ADLX ordinal");
|
||||||
|
assert!(!may_set("nvidia", false, -1), "Power control off: measure only");
|
||||||
|
assert!(may_set("nvidia", true, -1));
|
||||||
|
assert!(!may_set("apple", true, -1));
|
||||||
|
assert_eq!(retry_note(3599, "tuning stopped: the card refused the setting (see the log)"), "tuning: retry in 60 min (the card refused the setting (see the log))");
|
||||||
|
assert_eq!(retry_note(30, ""), "tuning: retry in 1 min");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_baseline_result_reads_measured_and_a_tune_reads_tuned() {
|
fn a_baseline_result_reads_measured_and_a_tune_reads_tuned() {
|
||||||
assert_eq!(result_line(PlanKind::Baseline, 114.2, 305.0, 0.3744), "Measured: 114.2 MH/s at 305 W (0.374 MH/W)");
|
assert_eq!(result_line(PlanKind::Baseline, 114.2, 305.0, 0.3744), "Measured: 114.2 MH/s at 305 W (0.374 MH/W)");
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ use std::sync::{Arc, Mutex};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
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;
|
const POW_EPOCH_BLOCKS: u64 = 3_600;
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
|
@ -48,6 +51,13 @@ pub enum Cmd {
|
||||||
WorkerBuilt(usize, Result<PathBuf, String>),
|
WorkerBuilt(usize, Result<PathBuf, String>),
|
||||||
/// local minus the latest block's timestamp, seconds (from the node's EVM RPC)
|
/// local minus the latest block's timestamp, seconds (from the node's EVM RPC)
|
||||||
ClockSample(f64),
|
ClockSample(f64),
|
||||||
|
/// another node holds the app's ports: what it answered (src/extnode.rs)
|
||||||
|
ExternalNode(crate::extnode::Check),
|
||||||
|
/// the node's own description over RPC (igneum_getNodeInfo), every 30 s
|
||||||
|
NodeInfo(crate::extnode::Check),
|
||||||
|
/// the network's view for the merge check (src/merge.rs): the highest blue score the observer shows, and the unix
|
||||||
|
/// ms of the newest block it marks as ours
|
||||||
|
NetView { sink_blue: u64, ours_ts_ms: Option<i64> },
|
||||||
/// local minus an HTTPS Date header, seconds; None when the check failed
|
/// local minus an HTTPS Date header, seconds; None when the check failed
|
||||||
ClockHttps(Option<f64>),
|
ClockHttps(Option<f64>),
|
||||||
ClockCheck,
|
ClockCheck,
|
||||||
|
|
@ -77,6 +87,8 @@ pub enum Cmd {
|
||||||
TuneProgress(Value),
|
TuneProgress(Value),
|
||||||
/// Ember 2: Settings > goal, electricity price, the hill-climb switch
|
/// Ember 2: Settings > goal, electricity price, the hill-climb switch
|
||||||
TuneGoal(Option<String>, Option<f64>, Option<bool>),
|
TuneGoal(Option<String>, Option<f64>, Option<bool>),
|
||||||
|
/// one card's goal override (key, goal; "" = back to the global goal)
|
||||||
|
TuneCardGoal(String, String),
|
||||||
/// restart the node with the verifier decided again (src/verifier.rs): the trust setting changed, or the
|
/// restart the node with the verifier decided again (src/verifier.rs): the trust setting changed, or the
|
||||||
/// prover found a host that was not there when the node started
|
/// prover found a host that was not there when the node started
|
||||||
RestartNode(String),
|
RestartNode(String),
|
||||||
|
|
@ -101,12 +113,17 @@ pub struct Shared {
|
||||||
port: std::sync::atomic::AtomicU16,
|
port: std::sync::atomic::AtomicU16,
|
||||||
/// set once `state_json` has logged a serialisation error (one line, not one per poll)
|
/// set once `state_json` has logged a serialisation error (one line, not one per poll)
|
||||||
state_error_logged: std::sync::atomic::AtomicBool,
|
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 {
|
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 {
|
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 settings_path = runtime.app_dir.join("settings.json");
|
||||||
let wallet_path = runtime.app_dir.join("wallet.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() };
|
let mut st = State { version: VERSION.into(), ..Default::default() };
|
||||||
st.network = runtime.network.clone();
|
st.network = runtime.network.clone();
|
||||||
st.chain = if runtime.network == "devnet" { "devnet v4".into() } else { format!("{} (private)", runtime.network) };
|
st.chain = if runtime.network == "devnet" { "devnet v4".into() } else { format!("{} (private)", runtime.network) };
|
||||||
|
|
@ -118,12 +135,13 @@ impl Shared {
|
||||||
st.setup_done = settings.setup_done;
|
st.setup_done = settings.setup_done;
|
||||||
st.phase = if settings.setup_done { "dashboard".into() } else { "welcome".into() };
|
st.phase = if settings.setup_done { "dashboard".into() } else { "welcome".into() };
|
||||||
st.node.state = "stopped".into();
|
st.node.state = "stopped".into();
|
||||||
|
st.node.tip_age_s = -1.0;
|
||||||
st.mining.state = "idle".into();
|
st.mining.state = "idle".into();
|
||||||
st.mining.paused = settings.paused;
|
st.mining.paused = settings.paused;
|
||||||
st.mining.accepted_total = settings.accepted_total;
|
st.mining.accepted_total = settings.accepted_total;
|
||||||
st.mining.fee_total = settings.fee_total;
|
st.mining.fee_total = settings.fee_total;
|
||||||
st.address = address_state(&settings, &wallet_path);
|
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.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.live_page = packaged.live_page.clone();
|
||||||
st.finality.message = "waiting for the miner".into();
|
st.finality.message = "waiting for the miner".into();
|
||||||
|
|
@ -145,9 +163,21 @@ impl Shared {
|
||||||
log_path,
|
log_path,
|
||||||
port: std::sync::atomic::AtomicU16::new(0),
|
port: std::sync::atomic::AtomicU16::new(0),
|
||||||
state_error_logged: std::sync::atomic::AtomicBool::new(false),
|
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:
|
/// 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,
|
/// 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.
|
/// so the restart after an OTA apply carries it again.
|
||||||
|
|
@ -187,6 +217,7 @@ impl Shared {
|
||||||
st.now = crate::platform::unix_now_f();
|
st.now = crate::platform::unix_now_f();
|
||||||
st.uptime_s = self.started.elapsed().as_secs();
|
st.uptime_s = self.started.elapsed().as_secs();
|
||||||
st.events = self.rings.lock().unwrap().events.iter().cloned().collect();
|
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) {
|
match serde_json::to_value(st) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
@ -323,7 +354,7 @@ impl Shared {
|
||||||
Ok(json!({ "ok": true }))
|
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 = Vec::new();
|
||||||
let mut restart_node: Option<String> = None;
|
let mut restart_node: Option<String> = None;
|
||||||
{
|
{
|
||||||
|
|
@ -357,6 +388,12 @@ impl Shared {
|
||||||
restart_node = Some(if v { "proof trust mode on (devnet only)".into() } else { "proof trust mode off".into() });
|
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 {
|
if let Some(a) = address {
|
||||||
let a = a.trim().to_ascii_lowercase();
|
let a = a.trim().to_ascii_lowercase();
|
||||||
if !a.is_empty() && a != s.address {
|
if !a.is_empty() && a != s.address {
|
||||||
|
|
@ -376,6 +413,7 @@ impl Shared {
|
||||||
st.settings.vote = s.vote;
|
st.settings.vote = s.vote;
|
||||||
st.settings.dev_fee = s.dev_fee;
|
st.settings.dev_fee = s.dev_fee;
|
||||||
st.settings.proof_verify_trust = s.proof_verify_trust;
|
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.on = s.dev_fee;
|
||||||
st.dev_fee.percent = if s.dev_fee { 1 } else { 0 };
|
st.dev_fee.percent = if s.dev_fee { 1 } else { 0 };
|
||||||
st.address = address_state(&s, &self.wallet_path);
|
st.address = address_state(&s, &self.wallet_path);
|
||||||
|
|
@ -532,6 +570,18 @@ pub struct Engine {
|
||||||
lines_tx: Sender<Line>,
|
lines_tx: Sender<Line>,
|
||||||
node: Option<Proc>,
|
node: Option<Proc>,
|
||||||
node_external: bool,
|
node_external: bool,
|
||||||
|
/// the merge check (src/merge.rs): the network's sinks as last seen, when one of our blocks last appeared in the
|
||||||
|
/// network's view, the merge depth from the node's params, when mining with a synced node began, the last poll
|
||||||
|
merge_sinks: Vec<u64>,
|
||||||
|
merge_ours_seen: Option<Instant>,
|
||||||
|
merge_depth: u64,
|
||||||
|
merge_mining_since: Option<Instant>,
|
||||||
|
merge_view_at: Instant,
|
||||||
|
/// the ports are held by a node this app refused (other rules or network): re-checked, the app takes the ports when it leaves
|
||||||
|
node_refused: bool,
|
||||||
|
/// when the other node's port stopped answering (engine seconds), None while it answers
|
||||||
|
external_gone_since: Option<f64>,
|
||||||
|
node_info_at: Instant,
|
||||||
node_restart_at: Option<Instant>,
|
node_restart_at: Option<Instant>,
|
||||||
/// resume rule (5 October 2026): when due, every enabled card must be mining or its name goes to the log
|
/// resume rule (5 October 2026): when due, every enabled card must be mining or its name goes to the log
|
||||||
resume_check_at: Option<Instant>,
|
resume_check_at: Option<Instant>,
|
||||||
|
|
@ -651,6 +701,14 @@ impl Engine {
|
||||||
lines_tx,
|
lines_tx,
|
||||||
node: None,
|
node: None,
|
||||||
node_external: false,
|
node_external: false,
|
||||||
|
merge_sinks: Vec::new(),
|
||||||
|
merge_ours_seen: None,
|
||||||
|
merge_depth: 0,
|
||||||
|
merge_mining_since: None,
|
||||||
|
merge_view_at: now,
|
||||||
|
node_refused: false,
|
||||||
|
external_gone_since: None,
|
||||||
|
node_info_at: Instant::now() - Duration::from_secs(25),
|
||||||
node_restart_at: None,
|
node_restart_at: None,
|
||||||
resume_check_at: None,
|
resume_check_at: None,
|
||||||
node_started_at: now,
|
node_started_at: now,
|
||||||
|
|
@ -939,6 +997,33 @@ impl Engine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Cmd::ExternalNode(check) => self.external_node_decided(check),
|
||||||
|
Cmd::NetView { sink_blue, ours_ts_ms } => {
|
||||||
|
self.merge_sinks = vec![sink_blue];
|
||||||
|
if let Some(ms) = ours_ts_ms {
|
||||||
|
let age = (crate::platform::unix_now_f() - ms as f64 / 1000.0).max(0.0);
|
||||||
|
self.merge_ours_seen = Some(Instant::now() - Duration::from_secs_f64(age.min(86_400.0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cmd::NodeInfo(info) => {
|
||||||
|
// the merge depth is compiled per network (not in params): blockrate.mergeDepth when the node says, else 3,600
|
||||||
|
if let Some(d) = info.merge_depth { self.merge_depth = d; }
|
||||||
|
if info.pow_engine.as_deref() == Some("stub") {
|
||||||
|
let first = self.st().node.state != "stub";
|
||||||
|
let mut st = self.st();
|
||||||
|
st.node.state = "stub".into();
|
||||||
|
st.node.synced = false;
|
||||||
|
st.node.message = "this node was built without the mining engine (stub) and refuses every real block".into();
|
||||||
|
drop(st);
|
||||||
|
if first { self.shared.event("error", "the node runs the stub engine, not igneum-pow: it refuses every real block. Reinstall the app (ledger N5)"); }
|
||||||
|
}
|
||||||
|
let mut st = self.st();
|
||||||
|
if let Some(d) = info.digest.filter(|d| d.len() >= 16) {
|
||||||
|
if st.node.consensus_digest != d { st.node.consensus_digest = d; }
|
||||||
|
st.node.digest_source = "rpc".into();
|
||||||
|
}
|
||||||
|
if let Some(v) = info.version { if !v.is_empty() && st.node.version.is_empty() { st.node.version = v; } }
|
||||||
|
}
|
||||||
Cmd::ClockSample(d) => {
|
Cmd::ClockSample(d) => {
|
||||||
self.clock_samples.push(d);
|
self.clock_samples.push(d);
|
||||||
if self.clock_samples.len() > 9 {
|
if self.clock_samples.len() > 9 {
|
||||||
|
|
@ -1147,6 +1232,26 @@ impl Engine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Cmd::TuneProgress(v) => self.tune_progress(&v),
|
Cmd::TuneProgress(v) => self.tune_progress(&v),
|
||||||
|
Cmd::TuneCardGoal(key, goal) => {
|
||||||
|
{
|
||||||
|
let mut s = self.shared.settings.lock().unwrap();
|
||||||
|
let (enabled, identities) = self.st().mining.cards.iter().find(|c| c.key == key).map(|c| (c.enabled, c.identities)).unwrap_or((true, 1));
|
||||||
|
let e = s.cards.entry(key.clone()).or_insert_with(|| crate::config::CardPref { enabled, identities, ..Default::default() });
|
||||||
|
e.tune_goal = goal.clone();
|
||||||
|
}
|
||||||
|
self.shared.save_settings();
|
||||||
|
let name = {
|
||||||
|
let mut st = self.st();
|
||||||
|
let c = st.mining.cards.iter_mut().find(|c| c.key == key);
|
||||||
|
c.map(|c| {
|
||||||
|
c.tune_goal = goal.clone();
|
||||||
|
c.name.clone()
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if let Some(name) = name {
|
||||||
|
self.shared.event("info", &format!("{name}: tune goal {}", if goal.is_empty() { "follows the global setting".to_string() } else { goal.clone() }));
|
||||||
|
}
|
||||||
|
}
|
||||||
Cmd::TuneGoal(goal, price, climb) => {
|
Cmd::TuneGoal(goal, price, climb) => {
|
||||||
{
|
{
|
||||||
let mut s = self.shared.settings.lock().unwrap();
|
let mut s = self.shared.settings.lock().unwrap();
|
||||||
|
|
@ -1359,14 +1464,22 @@ impl Engine {
|
||||||
let _ = std::fs::create_dir_all(&self.shared.runtime.log_dir);
|
let _ = std::fs::create_dir_all(&self.shared.runtime.log_dir);
|
||||||
self.apply_power_limits("start");
|
self.apply_power_limits("start");
|
||||||
if port_open(self.shared.runtime.rpc_port) {
|
if port_open(self.shared.runtime.rpc_port) {
|
||||||
self.node_external = true;
|
// another node holds the app's ports: check whose rules it runs before reading it (publish 2's read-back,
|
||||||
self.shared.event("info", &format!("a node already answers on 127.0.0.1:{}; using it (it is not stopped by this app)", self.shared.runtime.rpc_port));
|
// 6 October 2026: the Mac read the hand node silently as its own); the answer comes back as Cmd::ExternalNode
|
||||||
let mut st = self.st();
|
let port = self.shared.runtime.rpc_port;
|
||||||
st.node.state = "syncing".into();
|
{
|
||||||
st.node.message = "external node".into();
|
let mut st = self.st();
|
||||||
st.proving.verifier_reason = "external node: the app did not start it, so it set no verifier".into();
|
st.node.state = "starting".into();
|
||||||
st.proving.verifier_note = crate::verifier::note("unknown", "", "", true);
|
st.node.source = String::new();
|
||||||
|
st.node.message = format!("another node holds port {port} on this machine; checking its rules");
|
||||||
|
}
|
||||||
|
self.shared.log(&format!("port {port} is held by another node; asking it for its chain id and rules before using it"));
|
||||||
|
let shared = self.shared.clone();
|
||||||
|
let evm_port = self.shared.runtime.evm_port();
|
||||||
|
std::thread::spawn(move || { shared.send(Cmd::ExternalNode(crate::extnode::probe(evm_port))); });
|
||||||
} else {
|
} else {
|
||||||
|
crate::extnode::prune_logs(&self.shared.runtime.log_dir, "node-", 10);
|
||||||
|
self.set_node_mode("app", "own", "the ports were free at launch, so the app started its own node");
|
||||||
self.start_node();
|
self.start_node();
|
||||||
}
|
}
|
||||||
self.plan_miners();
|
self.plan_miners();
|
||||||
|
|
@ -1400,6 +1513,74 @@ impl Engine {
|
||||||
/// The packager's consensus parameters (igneum-app.json `node_override_params`, for example the difficulty v2
|
/// The packager's consensus parameters (igneum-app.json `node_override_params`, for example the difficulty v2
|
||||||
/// activation height) written to <app data>/override-params.json for --override-params-file. None when the
|
/// activation height) written to <app data>/override-params.json for --override-params-file. None when the
|
||||||
/// package pins nothing, so the node runs on the network's defaults as before.
|
/// package pins nothing, so the node runs on the network's defaults as before.
|
||||||
|
/// The check of another node on the app's ports came back (src/extnode.rs): use it and say so, or refuse it
|
||||||
|
fn external_node_decided(&mut self, check: crate::extnode::Check) {
|
||||||
|
let port = self.shared.runtime.p2p_port;
|
||||||
|
let ours = self.node_override_file().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str::<Value>(&t).ok());
|
||||||
|
let network = self.shared.runtime.network.clone();
|
||||||
|
let d = crate::extnode::decide_on(port, ours.as_ref(), None, Some(network.as_str()), &check);
|
||||||
|
self.node_external = d.use_it;
|
||||||
|
self.node_refused = !d.use_it;
|
||||||
|
self.external_gone_since = None;
|
||||||
|
self.set_node_mode(if d.use_it { "external" } else { "none" }, if d.use_it { "external" } else { "none" }, &d.line);
|
||||||
|
{
|
||||||
|
let mut st = self.st();
|
||||||
|
st.node.rules_check = d.verdict.into();
|
||||||
|
st.node.port_note = d.line.clone();
|
||||||
|
}
|
||||||
|
if d.use_it {
|
||||||
|
self.shared.event(if d.verdict == "match" { "info" } else { "error" }, &d.line);
|
||||||
|
let mut st = self.st();
|
||||||
|
st.node.state = "syncing".into();
|
||||||
|
st.node.message = "another node on this machine".into();
|
||||||
|
if let Some(dg) = check.digest.filter(|x| x.len() >= 16) { st.node.consensus_digest = dg; st.node.digest_source = "rpc".into(); }
|
||||||
|
if let Some(v) = check.version { if !v.is_empty() { st.node.version = v; } }
|
||||||
|
st.proving.verifier_reason = "external node: the app did not start it, so it set no verifier".into();
|
||||||
|
st.proving.verifier_note = crate::verifier::note("unknown", "", "", true);
|
||||||
|
} else {
|
||||||
|
{
|
||||||
|
let mut st = self.st();
|
||||||
|
st.node.state = "stopped".into();
|
||||||
|
st.node.synced = false;
|
||||||
|
st.node.message = d.line.clone();
|
||||||
|
}
|
||||||
|
self.shared.event("error", &d.line);
|
||||||
|
self.shared.event("error", "mining waits: stop the other node or start this app with other ports, then open the app again");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record which node the app reads (api/state node.source, node.mode, node.mode_reason).
|
||||||
|
fn set_node_mode(&self, source: &str, mode: &str, reason: &str) {
|
||||||
|
let mut st = self.st();
|
||||||
|
st.node.source = source.into();
|
||||||
|
st.node.mode = mode.into();
|
||||||
|
st.node.mode_reason = reason.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other node left its ports for TAKEOVER_WAIT_S: the app starts its own node on them and says so.
|
||||||
|
fn take_over_ports(&mut self) {
|
||||||
|
let port = self.shared.runtime.rpc_port;
|
||||||
|
let was = if self.node_external { "the node this app was reading" } else { "the node this app refused" };
|
||||||
|
self.node_external = false;
|
||||||
|
self.node_refused = false;
|
||||||
|
self.external_gone_since = None;
|
||||||
|
let reason = format!("{was} left port {port} for {} s, so the app started its own node", crate::extnode::TAKEOVER_WAIT_S as u64);
|
||||||
|
self.shared.event("info", &format!("The other node went away; this app starts its own node on port {port}"));
|
||||||
|
self.set_node_mode("app", "own", &reason);
|
||||||
|
{
|
||||||
|
let mut st = self.st();
|
||||||
|
st.node.rules_check = String::new();
|
||||||
|
st.node.port_note = reason.clone();
|
||||||
|
st.node.synced = false;
|
||||||
|
st.node.consensus_digest = String::new();
|
||||||
|
st.node.digest_source = String::new();
|
||||||
|
st.node.version = String::new();
|
||||||
|
}
|
||||||
|
self.node_last_reading = None;
|
||||||
|
crate::extnode::prune_logs(&self.shared.runtime.log_dir, "node-", 10);
|
||||||
|
self.start_node();
|
||||||
|
}
|
||||||
|
|
||||||
fn node_override_file(&self) -> Option<std::path::PathBuf> {
|
fn node_override_file(&self) -> Option<std::path::PathBuf> {
|
||||||
if self.node_override_unusable {
|
if self.node_override_unusable {
|
||||||
return None;
|
return None;
|
||||||
|
|
@ -1447,6 +1628,9 @@ impl Engine {
|
||||||
let mut st = self.st();
|
let mut st = self.st();
|
||||||
st.node.pid = p.pid();
|
st.node.pid = p.pid();
|
||||||
st.node.state = "starting".into();
|
st.node.state = "starting".into();
|
||||||
|
st.node.stall_exits = 0;
|
||||||
|
st.node.tip_age_s = -1.0;
|
||||||
|
st.node.sync_cause = String::new();
|
||||||
st.node.starts = self.node_starts;
|
st.node.starts = self.node_starts;
|
||||||
st.node.message = "opening the database".into();
|
st.node.message = "opening the database".into();
|
||||||
st.node.restart_in_s = 0;
|
st.node.restart_in_s = 0;
|
||||||
|
|
@ -1642,6 +1826,11 @@ impl Engine {
|
||||||
match procs::spawn(Source::Miner(card_idx), &self.bins.miner, &args, cwd.as_deref(), &log, &self.lines_tx, &envs) {
|
match procs::spawn(Source::Miner(card_idx), &self.bins.miner, &args, cwd.as_deref(), &log, &self.lines_tx, &envs) {
|
||||||
Ok(p) => {
|
Ok(p) => {
|
||||||
self.shared.log(&format!("miner {} started (pid {}): {}", self.miners[i].label, p.pid(), p.cmdline));
|
self.shared.log(&format!("miner {} started (pid {}): {}", self.miners[i].label, p.pid(), p.cmdline));
|
||||||
|
if self.miners[i].starts == 1 {
|
||||||
|
// one Activity line per card at its first start (an OTA relaunch shows every card coming back)
|
||||||
|
let name = self.st().mining.cards.get(self.miners[i].card).map(|c| c.name.clone()).unwrap_or_else(|| self.miners[i].label.clone());
|
||||||
|
self.shared.event("info", &format!("{name}: worker starting (pid {})", p.pid()));
|
||||||
|
}
|
||||||
let mut st = self.st();
|
let mut st = self.st();
|
||||||
if let Some(c) = st.mining.cards.get_mut(card_idx) {
|
if let Some(c) = st.mining.cards.get_mut(card_idx) {
|
||||||
c.pid = p.pid();
|
c.pid = p.pid();
|
||||||
|
|
@ -2219,8 +2408,23 @@ impl Engine {
|
||||||
if c.tune_control && c.vendor == "nvidia" && !power_control && !forced {
|
if c.tune_control && c.vendor == "nvidia" && !power_control && !forced {
|
||||||
// measure-only until Power control is on; the baseline plan covers it below
|
// measure-only until Power control is on; the baseline plan covers it below
|
||||||
}
|
}
|
||||||
if self.sweep_retry.get(&c.index).map(|t| now < *t).unwrap_or(false) {
|
// (E) 6 October 2026, PC 1 18:18Z: three "Tune now" requests sat behind the hour's back-off from the
|
||||||
continue;
|
// 17:55 failures and the rows said "queued" with no start; an explicit request now clears the back-off,
|
||||||
|
// and a held card's row says when the retry comes
|
||||||
|
if let Some(t) = self.sweep_retry.get(&c.index).copied() {
|
||||||
|
if now < t {
|
||||||
|
if forced {
|
||||||
|
self.sweep_retry.remove(&c.index);
|
||||||
|
} else {
|
||||||
|
if c.sweep_pct == 0 || c.sweep_note.starts_with("tuning stopped") {
|
||||||
|
let note = crate::ember::retry_note((t - now).as_secs(), &c.sweep_note);
|
||||||
|
if let Some(cc) = self.st().mining.cards.get_mut(c.index) {
|
||||||
|
cc.sweep_note = note;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let stable = self.miners.iter().find(|m| m.card == c.index).map(|m| m.proc.as_ref().map(|p| p.started.elapsed() >= Duration::from_secs(crate::ember::STABLE_S)).unwrap_or(false) && m.last_status.is_some()).unwrap_or(false);
|
let stable = self.miners.iter().find(|m| m.card == c.index).map(|m| m.proc.as_ref().map(|p| p.started.elapsed() >= Duration::from_secs(crate::ember::STABLE_S)).unwrap_or(false) && m.last_status.is_some()).unwrap_or(false);
|
||||||
let wait = if c.state != "mining" {
|
let wait = if c.state != "mining" {
|
||||||
|
|
@ -2399,7 +2603,8 @@ impl Engine {
|
||||||
let full_due = self.tune_full_due.remove(&idx);
|
let full_due = self.tune_full_due.remove(&idx);
|
||||||
let (goal, climb_on) = {
|
let (goal, climb_on) = {
|
||||||
let s = self.shared.settings.lock().unwrap();
|
let s = self.shared.settings.lock().unwrap();
|
||||||
(crate::ember::Goal::parse(&s.tune_goal), s.tune_climb)
|
// the card's own goal when set, else the global one
|
||||||
|
(crate::ember::goal_for(s.cards.get(&c.key).map(|p| p.tune_goal.as_str()).unwrap_or(""), &s.tune_goal), s.tune_climb)
|
||||||
};
|
};
|
||||||
let plan = if let Some(why) = control.as_ref() {
|
let plan = if let Some(why) = control.as_ref() {
|
||||||
if let Some(cc) = self.st().mining.cards.get_mut(idx) {
|
if let Some(cc) = self.st().mining.cards.get_mut(idx) {
|
||||||
|
|
@ -2567,16 +2772,47 @@ impl Engine {
|
||||||
}
|
}
|
||||||
let dev = device.to_string();
|
let dev = device.to_string();
|
||||||
let cmd = format!("{seq}0 dev {dev}\n{seq}1 pl {w}\n{seq}2 {}\n{seq}3 {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() }, if mem > 0 { format!("lmc {mem}") } else { "rmc".to_string() });
|
let cmd = format!("{seq}0 dev {dev}\n{seq}1 pl {w}\n{seq}2 {}\n{seq}3 {}\n", if clock > 0 { format!("lgc {clock}") } else { "rgc".to_string() }, if mem > 0 { format!("lmc {mem}") } else { "rmc".to_string() });
|
||||||
let _ = std::fs::write(self.helper_cmd_dir().join("cmd.txt"), cmd);
|
let dir = self.helper_cmd_dir();
|
||||||
// the helper polls twice a second and nvidia-smi answers within a second or two
|
let is_task = self.sweep_helper_is_task;
|
||||||
|
// 6 October 2026, PC 1: (B) the task's helper had exited while the engine's flag still said it ran,
|
||||||
|
// so commands went to nobody and a blind 4-second sleep called them acknowledged; (C) the first
|
||||||
|
// request of the first tune was written in the same second the helper started, and the helper
|
||||||
|
// skipped it as stale. Now, in the task case: the helper's heartbeat says whether it runs; the task
|
||||||
|
// is started and a fresh heartbeat awaited BEFORE the command is written; and the acknowledgement
|
||||||
|
// is the helper's own log line for this sequence (`<seq>3 nvidia-smi ...`), read for up to 15 s;
|
||||||
|
// a missing line is a failure with that reason
|
||||||
|
let want = format!(" {seq}3 nvidia-smi");
|
||||||
|
let log_file = dir.join("helper.log");
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
std::thread::sleep(Duration::from_secs(4));
|
if is_task {
|
||||||
shared.send(Cmd::TuneSet(seq, Ok("helper".into())));
|
if let Err(e) = crate::powertask::ensure_running(&dir, Duration::from_secs(12)) {
|
||||||
|
shared.send(Cmd::TuneSet(seq, Err(e)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = std::fs::create_dir_all(&dir);
|
||||||
|
let _ = std::fs::write(dir.join("cmd.txt"), cmd);
|
||||||
|
let until = Instant::now() + Duration::from_secs(if is_task { 15 } else { 6 });
|
||||||
|
loop {
|
||||||
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
|
let text = std::fs::read_to_string(&log_file).unwrap_or_default();
|
||||||
|
if let Some(line) = text.lines().rev().find(|l| l.contains(&want)) {
|
||||||
|
shared.send(Cmd::TuneSet(seq, Ok(format!("helper: {}", line.trim()))));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if Instant::now() >= until {
|
||||||
|
shared.send(Cmd::TuneSet(seq, Err(format!("the helper did not run sequence {seq} within {} s (no line in {})", if is_task { 15 } else { 6 }, log_file.display()))));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
"amd" => {
|
"amd" => {
|
||||||
let Some(exe) = self.bins.telemetry.clone() else { return };
|
let Some(exe) = self.bins.telemetry.clone() else { return };
|
||||||
if c.amd_ordinal < 0 {
|
// (D) 6 October 2026, PC 1 17:57Z: a measure-only card (the probe gave no tune line) was still sent a
|
||||||
|
// set, which the helper refused and the tune stopped; measure only means nothing is set, the run
|
||||||
|
// notices the missing acknowledgement and measures
|
||||||
|
if !crate::ember::may_set("amd", c.tune_control, c.amd_ordinal) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let n = c.amd_ordinal.to_string();
|
let n = c.amd_ordinal.to_string();
|
||||||
|
|
@ -2792,6 +3028,14 @@ impl Engine {
|
||||||
e.sweep_driver = c.driver.clone();
|
e.sweep_driver = c.driver.clone();
|
||||||
e.sweep_class = c.program_class.clone();
|
e.sweep_class = c.program_class.clone();
|
||||||
e.sweep_source = kind.name().into();
|
e.sweep_source = kind.name().into();
|
||||||
|
if kind == crate::ember::PlanKind::Full {
|
||||||
|
if let Some(b) = run.rows.first() {
|
||||||
|
e.sweep_before_watts = b.watts;
|
||||||
|
e.sweep_before_mhs = b.mhs;
|
||||||
|
c.tune_before_watts = b.watts;
|
||||||
|
c.tune_before_mhs = b.mhs;
|
||||||
|
}
|
||||||
|
}
|
||||||
if !c.pinned && kind != crate::ember::PlanKind::Baseline {
|
if !c.pinned && kind != crate::ember::PlanKind::Baseline {
|
||||||
e.power_pct = row.point.power_pct;
|
e.power_pct = row.point.power_pct;
|
||||||
}
|
}
|
||||||
|
|
@ -2907,6 +3151,25 @@ impl Engine {
|
||||||
self.last_awake = now;
|
self.last_awake = now;
|
||||||
crate::platform::keep_awake_tick();
|
crate::platform::keep_awake_tick();
|
||||||
}
|
}
|
||||||
|
if self.running && self.node.is_some() && !self.miners.is_empty() && now.duration_since(self.merge_view_at) >= Duration::from_secs(30) {
|
||||||
|
self.merge_view_at = now;
|
||||||
|
let shared = self.shared.clone();
|
||||||
|
let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&self.shared.packaged.live_page));
|
||||||
|
let ids: std::collections::HashSet<String> = self.st().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect();
|
||||||
|
if !live_api.is_empty() {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Some((sink_blue, ours_ts_ms)) = crate::merge::view_of(&crate::live::fetch(&live_api, 600, &ids)) {
|
||||||
|
shared.send(Cmd::NetView { sink_blue, ours_ts_ms });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.running && (self.node_external || self.node.is_some()) && now.duration_since(self.node_info_at) >= Duration::from_secs(30) {
|
||||||
|
self.node_info_at = now;
|
||||||
|
let shared = self.shared.clone();
|
||||||
|
let evm_port = self.shared.runtime.evm_port();
|
||||||
|
std::thread::spawn(move || { let c = crate::extnode::probe(evm_port); if c.digest.is_some() || c.version.is_some() { shared.send(Cmd::NodeInfo(c)); } });
|
||||||
|
}
|
||||||
if self.running {
|
if self.running {
|
||||||
self.tick_node(now);
|
self.tick_node(now);
|
||||||
self.tick_watch(now);
|
self.tick_watch(now);
|
||||||
|
|
@ -2977,7 +3240,9 @@ impl Engine {
|
||||||
node_synced: st.node.synced && st.clock.severity != "block",
|
node_synced: st.node.synced && st.clock.severity != "block",
|
||||||
boundary_eta_s: if st.node.daa > 0 && st.program.boundary_daa > 0 { Some(st.program.eta_s) } else { None },
|
boundary_eta_s: if st.node.daa > 0 && st.program.boundary_daa > 0 { Some(st.program.eta_s) } else { None },
|
||||||
// a remote job in progress counts as busy: no update applies under it (src/jobrun.rs)
|
// a remote job in progress counts as busy: no update applies under it (src/jobrun.rs)
|
||||||
miner_busy: self.miners.iter().any(|m| m.building) || st.mining.cards.iter().any(|c| c.enabled && c.state == "starting") || self.jobs.active(),
|
miner_busy: self.miners.iter().any(|m| m.building) || st.mining.cards.iter().any(|c| c.enabled && c.state == "starting"),
|
||||||
|
// a remote job in progress holds the update, urgent or not (src/manifest.rs safe_to_apply; PC 1, 6 October 2026)
|
||||||
|
job_active: self.jobs.active(),
|
||||||
daa: st.node.daa,
|
daa: st.node.daa,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -3155,12 +3420,24 @@ impl Engine {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn tick_node(&mut self, now: Instant) {
|
fn tick_node(&mut self, now: Instant) {
|
||||||
if self.node_external {
|
if self.node_external || self.node_refused {
|
||||||
if !port_open(self.shared.runtime.rpc_port) && now.duration_since(self.last_sync_check) >= Duration::from_secs(5) {
|
// re-decided every 5 s: the other node leaving hands the ports to this app after the wait (7 October 2026)
|
||||||
|
if now.duration_since(self.last_sync_check) >= Duration::from_secs(5) {
|
||||||
self.last_sync_check = now;
|
self.last_sync_check = now;
|
||||||
let mut st = self.st();
|
let port = self.shared.runtime.rpc_port;
|
||||||
st.node.state = "stopped".into();
|
let first = self.external_gone_since.is_none();
|
||||||
st.node.message = "the external node went away".into();
|
match crate::extnode::step(port_open(port), self.secs(now), &mut self.external_gone_since) {
|
||||||
|
crate::extnode::Step::Stay => {}
|
||||||
|
crate::extnode::Step::Gone { for_s } => {
|
||||||
|
let left = (crate::extnode::TAKEOVER_WAIT_S - for_s).max(0.0) as u64;
|
||||||
|
if first && self.node_external { self.shared.event("info", &format!("The other node on port {port} went away; if it stays away, this app starts its own node in {left} s")); }
|
||||||
|
let mut st = self.st();
|
||||||
|
st.node.state = "stopped".into();
|
||||||
|
st.node.synced = false;
|
||||||
|
st.node.message = format!("the other node on port {port} went away; this app starts its own node in {left} s unless it comes back");
|
||||||
|
}
|
||||||
|
crate::extnode::Step::TakeOver => self.take_over_ports(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -3306,6 +3583,30 @@ impl Engine {
|
||||||
} else if verdict != crate::watchdog::Action::None {
|
} else if verdict != crate::watchdog::Action::None {
|
||||||
// exit 43: the miner gave up on its worker; once more, then the card is faulted
|
// exit 43: the miner gave up on its worker; once more, then the card is faulted
|
||||||
self.watchdog_verdict(i, verdict, &tail);
|
self.watchdog_verdict(i, verdict, &tail);
|
||||||
|
} else if is_stall_exit(code, &tail) {
|
||||||
|
// N4: the miner found no new template for its stall span and stopped hashing and voting. Once:
|
||||||
|
// the miner restarts and the node gets another look. Twice since the node started: the node
|
||||||
|
// itself restarts and re-dials its peers (the seed list), because the miner alone cannot fix a
|
||||||
|
// node that fell off the network.
|
||||||
|
let n = { let mut st = self.st(); st.node.stall_exits += 1; st.node.stall_exits };
|
||||||
|
let label = self.miners[i].label.clone();
|
||||||
|
for l in &tail {
|
||||||
|
self.shared.log(&format!(" miner: {l}"));
|
||||||
|
}
|
||||||
|
if n >= 2 {
|
||||||
|
self.shared.event("error", &format!("{label}: no new block to work on, twice; the node restarts and re-dials its peers"));
|
||||||
|
self.st().node.stall_exits = 0;
|
||||||
|
self.restart_node("the miner saw no new block twice", Duration::from_secs(2));
|
||||||
|
} else {
|
||||||
|
let delay = jitter_secs(crate::platform::unix_now() + i as u64);
|
||||||
|
self.miners[i].restart_at = Some(now + Duration::from_secs(delay));
|
||||||
|
self.shared.event("error", &format!("{label}: no new block to work on for the stall span; the miner restarts in {delay} s (a second stall restarts the node)"));
|
||||||
|
if let Some(c) = self.st().mining.cards.get_mut(card_idx) {
|
||||||
|
c.state = "restarting".into();
|
||||||
|
c.hash_now = 0.0;
|
||||||
|
c.message = "no new block to work on; waiting for the node".into();
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
self.miners[i].restarts += 1;
|
self.miners[i].restarts += 1;
|
||||||
let delay = jitter_secs(crate::platform::unix_now() + i as u64);
|
let delay = jitter_secs(crate::platform::unix_now() + i as u64);
|
||||||
|
|
@ -3540,11 +3841,17 @@ impl Engine {
|
||||||
/// igneumd's own lines. One matters for the user: relayed blocks refused as "too far into the future", which is
|
/// 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).
|
/// 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) {
|
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) {
|
if let Some(d) = digest_from_line(text) {
|
||||||
let mut st = self.st();
|
let mut st = self.st();
|
||||||
if st.node.consensus_digest != d {
|
if st.node.consensus_digest != d {
|
||||||
st.node.consensus_digest = d;
|
st.node.consensus_digest = d;
|
||||||
}
|
}
|
||||||
|
if st.node.digest_source.is_empty() { st.node.digest_source = "log".into(); }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if !text.contains("too far into the future") {
|
if !text.contains("too far into the future") {
|
||||||
|
|
@ -3650,6 +3957,8 @@ impl Engine {
|
||||||
let blue = kv_u64(text, "blue").unwrap_or(0);
|
let blue = kv_u64(text, "blue").unwrap_or(0);
|
||||||
let difficulty = kv_f64(text, "difficulty").unwrap_or(0.0);
|
let difficulty = kv_f64(text, "difficulty").unwrap_or(0.0);
|
||||||
let flag = kv(text, "synced").map(|s| s == "true");
|
let flag = kv(text, "synced").map(|s| s == "true");
|
||||||
|
// N4: the sink header's age from the node (ca3-v4-0316 appends tip_age_s after synced=); -1 on an older node
|
||||||
|
let tip_age_s = kv(text, "tip_age_s").and_then(|s| s.parse::<f64>().ok()).unwrap_or(-1.0);
|
||||||
let was_synced = self.st().node.synced;
|
let was_synced = self.st().node.synced;
|
||||||
// Derived from every reading, never a one-shot transition (PC 2, 4 October 2026: the node caught up after a
|
// Derived from every reading, never a one-shot transition (PC 2, 4 October 2026: the node caught up after a
|
||||||
// clock fix and mined at 118 MH/s while the card still said "syncing", because getInfo's flag stayed false).
|
// clock fix and mined at 118 MH/s while the card still said "syncing", because getInfo's flag stayed false).
|
||||||
|
|
@ -3662,7 +3971,24 @@ impl Engine {
|
||||||
let stable_s = self.sync_stable_since.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(0.0);
|
let stable_s = self.sync_stable_since.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(0.0);
|
||||||
let accepted_recent = self.last_accepted.map(|t| now.duration_since(t) <= Duration::from_secs(60)).unwrap_or(false);
|
let accepted_recent = self.last_accepted.map(|t| now.duration_since(t) <= Duration::from_secs(60)).unwrap_or(false);
|
||||||
let private = self.shared.runtime.unsynced_mining && self.shared.runtime.peers.is_empty();
|
let private = self.shared.runtime.unsynced_mining && self.shared.runtime.peers.is_empty();
|
||||||
let synced = sync_decision(&Reading { blocks, headers, peers, flag }, caught_up_moving, stable_s, accepted_recent, private);
|
let (mut synced, mut cause) = sync_decision_v2(&Reading { blocks, headers, peers, flag }, caught_up_moving, stable_s, accepted_recent, private, tip_age_s);
|
||||||
|
// 7 October 2026: a synced-looking node whose blocks never merge into the network reads "behind" and holds the miner
|
||||||
|
if synced && !private {
|
||||||
|
let since = *self.merge_mining_since.get_or_insert(now);
|
||||||
|
let check = crate::merge::MergeCheck {
|
||||||
|
our_blue: blue,
|
||||||
|
peer_sinks: self.merge_sinks.clone(),
|
||||||
|
merge_depth: self.merge_depth,
|
||||||
|
ours_seen_ago_s: self.merge_ours_seen.map(|t| now.duration_since(t).as_secs_f64()),
|
||||||
|
mining_for_s: now.duration_since(since).as_secs_f64(),
|
||||||
|
};
|
||||||
|
if crate::merge::not_merging(&check) {
|
||||||
|
synced = false;
|
||||||
|
cause = "not merging";
|
||||||
|
}
|
||||||
|
} else if !synced {
|
||||||
|
self.merge_mining_since = None;
|
||||||
|
}
|
||||||
self.sync_prev = Some(blocks);
|
self.sync_prev = Some(blocks);
|
||||||
// the first clock source: local time against the latest block the peers produced, once blocks arrive
|
// the first clock source: local time against the latest block the peers produced, once blocks arrive
|
||||||
if blocks > 0 && (peers > 0 || self.shared.runtime.peers.is_empty()) && now.duration_since(self.clock_last_sample) >= Duration::from_secs(9) {
|
if blocks > 0 && (peers > 0 || self.shared.runtime.peers.is_empty()) && now.duration_since(self.clock_last_sample) >= Duration::from_secs(9) {
|
||||||
|
|
@ -3685,20 +4011,41 @@ impl Engine {
|
||||||
st.node.blue = blue;
|
st.node.blue = blue;
|
||||||
st.node.difficulty = difficulty;
|
st.node.difficulty = difficulty;
|
||||||
st.node.synced = synced;
|
st.node.synced = synced;
|
||||||
st.node.state = if synced { "synced".into() } else { "syncing".into() };
|
st.node.tip_age_s = tip_age_s;
|
||||||
|
st.node.sync_cause = cause.to_string();
|
||||||
|
// the state word: "behind" for a frozen tip, "no peers" for none, "syncing" for the rest (N4: never
|
||||||
|
// "synced" on a tip that stopped moving)
|
||||||
|
st.node.state = if synced { "synced".into() } else if cause == "frozen" || cause == "not merging" { "behind".into() } else if cause == "no peers" { "no peers".into() } else { "syncing".into() };
|
||||||
st.node.message = if synced {
|
st.node.message = if synced {
|
||||||
String::new()
|
String::new()
|
||||||
|
} else if cause == "not merging" {
|
||||||
|
crate::merge::NOT_MERGING.to_string()
|
||||||
|
} else if cause == "frozen" {
|
||||||
|
format!("no new block for {} s with {peers} peer{}; the chain may have moved on without this node", tip_age_s.round() as i64, if peers == 1 { "" } else { "s" })
|
||||||
} else if peers == 0 {
|
} else if peers == 0 {
|
||||||
"waiting for a peer".into()
|
"waiting for a peer on our chain".into()
|
||||||
} else if headers > blocks + 2 {
|
} else if headers > blocks + 2 {
|
||||||
format!("{blocks} of {headers} blocks")
|
format!("{blocks} of {headers} blocks")
|
||||||
} else {
|
} else {
|
||||||
"caught up, waiting for the next block".into()
|
"caught up, waiting for the next block".into()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if !synced && was_synced && cause == "not merging" {
|
||||||
|
self.shared.event("error", &format!("{}; the network's sink is {} blocks past ours, so the miner is held until this node merges again", crate::merge::NOT_MERGING, self.merge_sinks.iter().max().copied().unwrap_or(0).saturating_sub(blue)));
|
||||||
|
}
|
||||||
if synced && !was_synced {
|
if synced && !was_synced {
|
||||||
self.shared.event("ok", &format!("node synced: {blocks} blocks, {peers} peer(s)"));
|
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() {
|
for m in self.miners.iter_mut() {
|
||||||
|
// a card faulted for silence while the node was not ready starts again now (PC 1, 7 October 2026)
|
||||||
|
if m.watch.faulted_by_node() {
|
||||||
|
m.watch.event(t, crate::watchdog::Event::NodeSynced);
|
||||||
|
let name = names.get(m.card).cloned().unwrap_or_else(|| m.label.clone());
|
||||||
|
self.shared.event("info", &format!("{name}: starts again now the node is synced (it was faulted for silence while the node was syncing)"));
|
||||||
|
}
|
||||||
if m.proc.is_none() && m.restart_at.is_none() && m.watch.faulted().is_none() {
|
if m.proc.is_none() && m.restart_at.is_none() && m.watch.faulted().is_none() {
|
||||||
m.restart_at = Some(now);
|
m.restart_at = Some(now);
|
||||||
}
|
}
|
||||||
|
|
@ -3714,6 +4061,22 @@ impl Engine {
|
||||||
let Some(i) = self.miners.iter().position(|m| m.card == card) else { return };
|
let Some(i) = self.miners.iter().position(|m| m.card == card) else { return };
|
||||||
let label = self.miners[i].label.clone();
|
let label = self.miners[i].label.clone();
|
||||||
let card_name = self.st().mining.cards.get(card).map(|c| c.name.clone()).unwrap_or_else(|| label.clone());
|
let card_name = self.st().mining.cards.get(card).map(|c| c.name.clone()).unwrap_or_else(|| label.clone());
|
||||||
|
if text.contains("template fetch timed out") {
|
||||||
|
// PC 1, 7 October 2026 04:51Z: the node reported synced while its finality replay blocked the template
|
||||||
|
// RPC for three minutes; the miner printed only these lines, and the watchdog faulted every card for
|
||||||
|
// silence. The line is the miner's heartbeat: the card waits on the node, with one Activity line per episode
|
||||||
|
let now = Instant::now();
|
||||||
|
let t = self.secs(now);
|
||||||
|
let first = !self.miners[i].watch.templates_blocked();
|
||||||
|
self.miners[i].watch.event(t, crate::watchdog::Event::TemplateTimeout);
|
||||||
|
if first {
|
||||||
|
self.shared.event("info", &format!("{card_name}: the node is not answering block templates yet; the miner keeps asking (the node catches up after a restart)"));
|
||||||
|
}
|
||||||
|
if let Some(c) = self.st().mining.cards.get_mut(card) {
|
||||||
|
c.message = "waiting for the node to answer block templates".into();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if stderr {
|
if stderr {
|
||||||
if text.contains(" rejected nonce=") {
|
if text.contains(" rejected nonce=") {
|
||||||
if let Some(run) = self.sweep.as_mut() {
|
if let Some(run) = self.sweep.as_mut() {
|
||||||
|
|
@ -3799,8 +4162,16 @@ impl Engine {
|
||||||
if let Some(c) = st.mining.cards.get_mut(card) {
|
if let Some(c) = st.mining.cards.get_mut(card) {
|
||||||
c.accepted += 1;
|
c.accepted += 1;
|
||||||
}
|
}
|
||||||
|
let card_key = st.mining.cards.get(card).map(|c| c.key.clone()).unwrap_or_default();
|
||||||
drop(st);
|
drop(st);
|
||||||
self.shared.event("block", &format!("block accepted by the node ({card_name})"));
|
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) {
|
} else if let Some(s) = crate::watchdog::parse_status(text) {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
self.miners[i].last_status = Some(now);
|
self.miners[i].last_status = Some(now);
|
||||||
|
|
@ -3839,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() };
|
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 ") {
|
} else if text.contains(" worker: race ") {
|
||||||
self.race_line(card, &card_name, text);
|
self.race_line(card, &card_name, text);
|
||||||
} else if text.contains(" worker: ready ") {
|
} else if text.contains(" worker: ready ") {
|
||||||
|
|
@ -3900,9 +4273,16 @@ impl Engine {
|
||||||
st.finality.last_lock_at = crate::platform::unix_now_f();
|
st.finality.last_lock_at = crate::platform::unix_now_f();
|
||||||
st.finality.age_s = 0.0;
|
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=") {
|
} else if text.contains(" VOTE index=") {
|
||||||
self.st().finality.votes += 1;
|
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 ") {
|
} else if text.contains("worker could not prepare") || text.contains(" prepare-failed ") {
|
||||||
self.st().program.prepared = false;
|
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"));
|
self.shared.event("error", &format!("{card_name}: the worker could not compile the next program; it compiles at the boundary"));
|
||||||
|
|
@ -4227,6 +4607,38 @@ pub fn sync_decision(r: &Reading, moving: bool, stable_s: f64, accepted_recent:
|
||||||
caught_up && (moving || stable_s >= 60.0)
|
caught_up && (moving || stable_s >= 60.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The tip age at which a node that still says "synced" is treated as frozen (ledger N4, 6 October 2026: a home
|
||||||
|
/// miner's one same-digest peer relayed nothing for 53 minutes while the app read "mining, synced").
|
||||||
|
pub const TIP_FROZEN_S: f64 = 120.0;
|
||||||
|
|
||||||
|
/// N4: what "synced" means on every surface. The old decision, AND the tip moved in the last 120 s (the watch
|
||||||
|
/// line's tip_age_s, the sink header's age; -1 = the node does not report it yet, which gates nothing so an older node
|
||||||
|
/// keeps today's words), AND at least one peer (the handshake refuses a peer with another digest, so `peers` already
|
||||||
|
/// counts peers on our chain). Returns (synced, cause): the cause is "" when synced, else "frozen" (a peer, caught up,
|
||||||
|
/// no new block for over 120 s), "no peers", "syncing", or "behind" (the node's own flag false while the count moves).
|
||||||
|
pub fn sync_decision_v2(r: &Reading, moving: bool, stable_s: f64, accepted_recent: bool, private: bool, tip_age_s: f64) -> (bool, &'static str) {
|
||||||
|
if private {
|
||||||
|
return (true, "");
|
||||||
|
}
|
||||||
|
let frozen = tip_age_s > TIP_FROZEN_S && !accepted_recent;
|
||||||
|
if r.peers == 0 && !accepted_recent {
|
||||||
|
return (false, "no peers");
|
||||||
|
}
|
||||||
|
if frozen {
|
||||||
|
return (false, "frozen");
|
||||||
|
}
|
||||||
|
if sync_decision(r, moving, stable_s, accepted_recent, private) {
|
||||||
|
return (true, "");
|
||||||
|
}
|
||||||
|
(false, if r.headers > r.blocks + 2 { "syncing" } else { "behind" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The miner's stall exit (the node lane, ca3-v4-0316): code 45, or a tail line starting "STALLED '".
|
||||||
|
pub const STALL_EXIT_CODE: i32 = 45;
|
||||||
|
pub fn is_stall_exit(code: i32, tail: &[String]) -> bool {
|
||||||
|
code == STALL_EXIT_CODE || tail.iter().any(|l| l.contains(" STALLED '") || l.starts_with("STALLED '"))
|
||||||
|
}
|
||||||
|
|
||||||
/// "...block timestamp is 1791112663000 but maximum timestamp allowed is 1791112600000" -> at least 63 s behind.
|
/// "...block timestamp is 1791112663000 but maximum timestamp allowed is 1791112600000" -> at least 63 s behind.
|
||||||
fn behind_from_warning(text: &str) -> Option<f64> {
|
fn behind_from_warning(text: &str) -> Option<f64> {
|
||||||
let nums: Vec<f64> = text.split(|c: char| !c.is_ascii_digit()).filter(|s| s.len() >= 12).filter_map(|s| s.parse::<f64>().ok()).collect();
|
let nums: Vec<f64> = text.split(|c: char| !c.is_ascii_digit()).filter(|s| s.len() >= 12).filter_map(|s| s.parse::<f64>().ok()).collect();
|
||||||
|
|
@ -4385,7 +4797,7 @@ mod tests {
|
||||||
assert_eq!(super::power_control_after_prompt(&Err("the elevated step exited with code 2".into())), None);
|
assert_eq!(super::power_control_after_prompt(&Err("the elevated step exited with code 2".into())), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
use super::{digest_from_line, parse_race, switches_of, sync_decision, Reading};
|
use super::{is_stall_exit, sync_decision_v2, digest_from_line, parse_race, switches_of, sync_decision, Reading};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn digest_comes_from_the_nodes_own_line_only() {
|
fn digest_comes_from_the_nodes_own_line_only() {
|
||||||
|
|
@ -4457,6 +4869,44 @@ mod tests {
|
||||||
assert!(sync_decision(&r(0, 0, 0, None), false, 0.0, false, true));
|
assert!(sync_decision(&r(0, 0, 0, None), false, 0.0, false, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ledger N4 (6 October 2026): a home miner's node kept one same-digest peer that relayed nothing for 53 minutes;
|
||||||
|
/// today's rule said "synced" the whole time. The known-failed case first, then the fix.
|
||||||
|
#[test]
|
||||||
|
fn n4_a_frozen_tip_is_never_synced() {
|
||||||
|
// the frozen tip: caught up, one peer, the count stable for an hour, getInfo's flag true. Today: synced.
|
||||||
|
let frozen = r(133_000, 133_000, 1, Some(true));
|
||||||
|
assert!(sync_decision(&frozen, false, 3_180.0, false, false), "the old rule says synced on the frozen tip (the N4 failure)");
|
||||||
|
// the fix: the tip age gates it
|
||||||
|
let (ok, cause) = sync_decision_v2(&frozen, false, 3_180.0, false, false, 3_180.0);
|
||||||
|
assert!(!ok);
|
||||||
|
assert_eq!(cause, "frozen");
|
||||||
|
// the same node moving: synced
|
||||||
|
assert_eq!(sync_decision_v2(&frozen, true, 0.0, false, false, 4.0), (true, ""));
|
||||||
|
// exactly at the bound is still synced; one second over is not
|
||||||
|
assert_eq!(sync_decision_v2(&frozen, false, 60.0, false, false, 120.0), (true, ""));
|
||||||
|
assert_eq!(sync_decision_v2(&frozen, false, 60.0, false, false, 121.0), (false, "frozen"));
|
||||||
|
// an older node that does not report the age (-1) keeps today's words
|
||||||
|
assert_eq!(sync_decision_v2(&frozen, false, 60.0, false, false, -1.0), (true, ""));
|
||||||
|
// our own block accepted in the last minute proves the tip moves, whatever the reported age
|
||||||
|
assert_eq!(sync_decision_v2(&frozen, false, 60.0, true, false, 900.0), (true, ""));
|
||||||
|
// no peer at all: "no peers", not "frozen" and never synced
|
||||||
|
assert_eq!(sync_decision_v2(&r(133_000, 133_000, 0, Some(true)), false, 600.0, false, false, 500.0), (false, "no peers"));
|
||||||
|
// still downloading: "syncing"
|
||||||
|
assert_eq!(sync_decision_v2(&r(100, 700, 1, Some(false)), true, 0.0, false, false, 2.0), (false, "syncing"));
|
||||||
|
// caught up but the flag false and the count not yet stable: "behind"
|
||||||
|
assert_eq!(sync_decision_v2(&r(700, 700, 1, Some(false)), false, 10.0, false, false, 5.0), (false, "behind"));
|
||||||
|
// a private test node is always synced
|
||||||
|
assert_eq!(sync_decision_v2(&r(0, 0, 0, None), false, 0.0, false, true, 9_999.0), (true, ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn n4_the_stall_exit_is_code_45_or_the_stalled_line() {
|
||||||
|
assert!(is_stall_exit(45, &[]));
|
||||||
|
assert!(is_stall_exit(1, &["1791327000.123 STALLED 'nvidia-1ccfe586-1': no new template for 300 s (tip d3dc2f78, daa 197752); the node has fallen off the network or stopped moving; hashing and voting paused, exiting with code 45 so the launcher restarts (and after a second stall, restarts the node)".to_string()]));
|
||||||
|
assert!(!is_stall_exit(1, &["worker fault: rejected nonce".to_string()]));
|
||||||
|
assert!(!is_stall_exit(42, &[]));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn node_warning() {
|
fn node_warning() {
|
||||||
let l = "2026-10-04 11:02:11.123+00:00 [WARN ] HandleRelayInvsFlow flow error: the block timestamp is too far into the future: block timestamp is 1791112663000 but maximum timestamp allowed is 1791112600000";
|
let l = "2026-10-04 11:02:11.123+00:00 [WARN ] HandleRelayInvsFlow flow error: the block timestamp is too far into the future: block timestamp is 1791112663000 but maximum timestamp allowed is 1791112600000";
|
||||||
|
|
|
||||||
149
app/igneum-app/src/execrpc.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
//! The one path to the node's execution-layer JSON-RPC (ledger N7, 7 October 2026, main's rule for 0.3.19).
|
||||||
|
//!
|
||||||
|
//! A node before the exec RPC bounds fix (every 0.3.17 node) dies when a method that resolves a block number or indexes
|
||||||
|
//! the record vector is asked while its exec follower holds no record: `rpc.rs` indexes `records[0]` (or slices
|
||||||
|
//! `records[1..=0]`) on an empty vector, the panic hook exits the process, and the app restarts it. PC 1 crash-looped on
|
||||||
|
//! two callers in one night (the clock sample's eth_getBlockByNumber, then the prover's igneum_getAssignedShards after the
|
||||||
|
//! node read synced seconds before a slow follower loaded). The node-side fix ships with the 0.3.20 node; a 0.3.19 app on a
|
||||||
|
//! 0.3.17 node must be safe by itself, so every exec RPC call the app makes goes through [`call`]: a method in
|
||||||
|
//! [`SAFE_ON_EMPTY`] goes out at once; any other waits until igneum_getExecStatus reports an executed tip. The unit test
|
||||||
|
//! below enumerates the callers: no other file may build an exec JSON-RPC request, and every method name in the tree must
|
||||||
|
//! be classified here, so a new caller or method cannot bypass the gate.
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::process::Command;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Methods that index nothing on an empty exec state (read from the 0.3.17 node's rpc.rs): safe at any time.
|
||||||
|
pub const SAFE_ON_EMPTY: &[&str] = &[
|
||||||
|
"eth_chainId", "eth_blockNumber", "eth_syncing", "igneum_getExecStatus", "igneum_getProvingStatus", "igneum_getNodeInfo",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Methods the app sends that resolve a block number, index or slice the record vector, or simulate at a block: held until
|
||||||
|
/// the follower holds a record. Every method literal outside this module must be in one of the two lists.
|
||||||
|
pub const GATED: &[&str] = &[
|
||||||
|
"eth_getBlockByNumber", "igneum_getAssignedShards", "igneum_getProofRecords", "igneum_getSegmentRecords", "igneum_getSegmentStatement",
|
||||||
|
"igneum_getProofBytes", "igneum_getSegmentProofBytes", "igneum_exportSegments", "igneum_submitProofRecord", "igneum_submitSegmentRecord",
|
||||||
|
"igneum_getFinalityWeights",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// One JSON-RPC POST to 127.0.0.1:<evm_port> through curl (the engine carries no HTTP client); the body goes through a file
|
||||||
|
/// so a large export request is not an argument. The reply's `result` (null allowed), or the error's message.
|
||||||
|
fn post(evm_port: u16, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
|
||||||
|
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
|
||||||
|
let tmp = std::env::temp_dir().join(format!("igneum-rpc-{}-{}-{}.json", std::process::id(), method, crate::platform::unix_now_f() as u64));
|
||||||
|
std::fs::write(&tmp, body).map_err(|e| e.to_string())?;
|
||||||
|
let url = format!("http://127.0.0.1:{evm_port}");
|
||||||
|
let out = crate::detect::run_timeout(
|
||||||
|
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", &timeout.as_secs().max(1).to_string(), "-X", "POST", &url, "-H", "Content-Type: application/json", "-d", &format!("@{}", tmp.display())]),
|
||||||
|
None,
|
||||||
|
timeout + Duration::from_secs(2),
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(&tmp);
|
||||||
|
let out = out.ok_or_else(|| format!("{method}: the node's RPC did not answer"))?;
|
||||||
|
let v: Value = serde_json::from_str(&out).map_err(|e| format!("{method}: {e}"))?;
|
||||||
|
if let Some(err) = v.get("error") {
|
||||||
|
return Err(format!("{method}: {}", err.get("message").and_then(|m| m.as_str()).unwrap_or("error")));
|
||||||
|
}
|
||||||
|
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True once the node's exec follower holds a record (igneum_getExecStatus's executedTipHash is set). Any error or an
|
||||||
|
/// unreachable node reads false: the gated call waits rather than asks.
|
||||||
|
pub fn has_record(evm_port: u16) -> bool {
|
||||||
|
match post(evm_port, "igneum_getExecStatus", json!([]), Duration::from_secs(5)) {
|
||||||
|
Ok(r) => status_has_record(&r),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reading of an igneum_getExecStatus result: a record is held when executedTipHash is a non-empty string.
|
||||||
|
pub fn status_has_record(result: &Value) -> bool {
|
||||||
|
result.get("executedTipHash").and_then(|h| h.as_str()).map(|h| !h.is_empty()).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The gate: a safe method goes out; a gated one waits for a record; an unclassified method is refused (add it to a list).
|
||||||
|
pub fn call(evm_port: u16, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
|
||||||
|
if SAFE_ON_EMPTY.contains(&method) {
|
||||||
|
return post(evm_port, method, params, timeout);
|
||||||
|
}
|
||||||
|
if !GATED.contains(&method) {
|
||||||
|
return Err(format!("{method}: not classified in execrpc (safe on an empty state, or gated); add it before calling"));
|
||||||
|
}
|
||||||
|
if !has_record(evm_port) {
|
||||||
|
return Err(format!("{method}: the node's execution layer holds no record yet"));
|
||||||
|
}
|
||||||
|
post(evm_port, method, params, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_reading() {
|
||||||
|
assert!(status_has_record(&json!({"executedTip":"0x38a5","executedTipHash":"0xa3ae37ec"})));
|
||||||
|
assert!(!status_has_record(&json!({"executedTip":"0x0","executedTipHash":null})));
|
||||||
|
assert!(!status_has_record(&json!({})));
|
||||||
|
assert!(!status_has_record(&json!({"executedTipHash":""})));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lists_are_disjoint_and_sorted_enough() {
|
||||||
|
for m in GATED {
|
||||||
|
assert!(!SAFE_ON_EMPTY.contains(m), "{m} in both lists");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ledger N7: every exec JSON-RPC request the app builds goes through this module, and every exec method name in the
|
||||||
|
/// tree is classified here. A new direct caller (a file that builds a "jsonrpc" POST) or an unclassified method fails.
|
||||||
|
#[test]
|
||||||
|
fn every_caller_goes_through_the_gate() {
|
||||||
|
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||||
|
// every eth_* or igneum_* identifier on a line (a hand scanner: no regex crate in this binary)
|
||||||
|
fn methods_in(line: &str) -> Vec<String> {
|
||||||
|
// ASCII-only scan over char boundaries: a token of [A-Za-z0-9_] that starts with eth_ or igneum_
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut token = String::new();
|
||||||
|
let mut flush = |t: &mut String| {
|
||||||
|
if t.starts_with("eth_") || t.starts_with("igneum_") { out.push(t.clone()); }
|
||||||
|
t.clear();
|
||||||
|
};
|
||||||
|
for ch in line.chars() {
|
||||||
|
if ch.is_ascii_alphanumeric() || ch == '_' { token.push(ch); } else { flush(&mut token); }
|
||||||
|
}
|
||||||
|
flush(&mut token);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
let mut offenders = Vec::new();
|
||||||
|
let mut unclassified = Vec::new();
|
||||||
|
for entry in std::fs::read_dir(&src).unwrap() {
|
||||||
|
let path = entry.unwrap().path();
|
||||||
|
if path.extension().and_then(|e| e.to_str()) != Some("rs") { continue; }
|
||||||
|
let name = path.file_name().unwrap().to_string_lossy().to_string();
|
||||||
|
let text = std::fs::read_to_string(&path).unwrap();
|
||||||
|
// a JSON-RPC REQUEST names a method next to "jsonrpc" (replies and test fixtures carry "result" or "error");
|
||||||
|
// only this module may build one
|
||||||
|
if name != "execrpc.rs" {
|
||||||
|
for (i, line) in text.lines().enumerate() {
|
||||||
|
let l = line.replace('\\', "");
|
||||||
|
if l.contains("\"jsonrpc\"") && l.contains("\"method\"") && !l.contains("\"result\"") && !l.contains("\"error\"") {
|
||||||
|
offenders.push(format!("{name}:{}", i + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (i, line) in text.lines().enumerate() {
|
||||||
|
if line.trim_start().starts_with("//") { continue; }
|
||||||
|
for m in methods_in(line) {
|
||||||
|
let m = m.as_str();
|
||||||
|
// identifiers that are not RPC methods: crate and file names (igneum_app, igneum_miner, ...) carry no camel-case method part
|
||||||
|
let looks_like_method = m.contains("_get") || m.contains("_submit") || m.contains("_export") || m.contains("_estimate") || m.contains("_send") || m == "eth_chainId" || m == "eth_blockNumber" || m == "eth_syncing";
|
||||||
|
if looks_like_method && !SAFE_ON_EMPTY.contains(&m) && !GATED.contains(&m) {
|
||||||
|
unclassified.push(format!("{name}:{}: {m}", i + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(offenders.is_empty(), "exec JSON-RPC built outside execrpc.rs: {offenders:?}");
|
||||||
|
assert!(unclassified.is_empty(), "exec methods not classified in execrpc.rs: {unclassified:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
242
app/igneum-app/src/extnode.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
//! Another node on this machine (publish 2's read-back, 6 October 2026): the hand node on the Mac held 26610/26611,
|
||||||
|
//! the app's default RPC and p2p ports, the app read its state as its own and the digest field read empty. Now the
|
||||||
|
//! collision is loud on every surface and checked: the other node's chain id (eth_chainId on the EVM port) and its
|
||||||
|
//! consensus overrides (`igneum_getNodeInfo`, the node lane, 7 October 2026; "method not found" on an older node) against the
|
||||||
|
//! override file the app would start its own node with. A match: "Another node holds port N on this machine; using
|
||||||
|
//! it". A mismatch: "Node not started: port N is taken" and the app does not read that node. Unknown (an older node
|
||||||
|
//! that cannot answer): used, and said so. The same RPC gives the digest, so api/state carries it for an external node
|
||||||
|
//! too, read every 30 s instead of parsed from a stdout the app does not own.
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::path::Path;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// What the other node answered: None where it could not answer.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct Check {
|
||||||
|
pub chain_id: Option<u64>,
|
||||||
|
pub digest: Option<String>,
|
||||||
|
pub network: Option<String>,
|
||||||
|
pub version: Option<String>,
|
||||||
|
/// the node's consensus params as it resolved them (`params` in igneum_getNodeInfo, every field by its override-file name)
|
||||||
|
pub overrides: Option<Value>,
|
||||||
|
/// "igneum-pow" or "stub" (ledger N5: a node built without the mining engine refuses every real block)
|
||||||
|
pub pow_engine: Option<String>,
|
||||||
|
/// the network's compiled merge depth in blue score (`blockrate.mergeDepth`, the node lane's addition of 7 October 2026);
|
||||||
|
/// None on a node without the object, and the app assumes 3,600 (1 bps)
|
||||||
|
pub merge_depth: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decision for the port-collision path.
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub struct Decision {
|
||||||
|
pub use_it: bool,
|
||||||
|
/// match | mismatch | unknown
|
||||||
|
pub verdict: &'static str,
|
||||||
|
/// the event line, in a user's words
|
||||||
|
pub line: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compare the other node with ours: `ours` is the override file the app would start its own node with (the
|
||||||
|
/// `*_activation_daa` and friends), `our_chain` the chain id this app's network uses when known.
|
||||||
|
pub fn decide(port: u16, ours: Option<&Value>, our_chain: Option<u64>, theirs: &Check) -> Decision { decide_on(port, ours, our_chain, None, theirs) }
|
||||||
|
|
||||||
|
/// `decide` with the network name too (devnet | simnet | testnet): the node lane's rule is that `network` must be equal.
|
||||||
|
pub fn decide_on(port: u16, ours: Option<&Value>, our_chain: Option<u64>, our_network: Option<&str>, theirs: &Check) -> Decision {
|
||||||
|
let mut checked = false;
|
||||||
|
if let (Some(a), Some(b)) = (our_network.filter(|s| !s.is_empty()), theirs.network.as_deref().filter(|s| !s.is_empty())) {
|
||||||
|
checked = true;
|
||||||
|
if a != b {
|
||||||
|
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on another network ({b}, ours {a})") };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let (Some(a), Some(b)) = (our_chain, theirs.chain_id) {
|
||||||
|
checked = true;
|
||||||
|
if a != b {
|
||||||
|
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on another network (chain id {b}, ours {a})") };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if theirs.pow_engine.as_deref() == Some("stub") {
|
||||||
|
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node built without the mining engine (stub), which refuses every real block") };
|
||||||
|
}
|
||||||
|
// every key the manifest sets, against the node's resolved params (the node lane: compare values, never recompute the hash)
|
||||||
|
if let (Some(o), Some(t)) = (ours.and_then(|v| v.as_object()), theirs.overrides.as_ref().and_then(|v| v.as_object())) {
|
||||||
|
checked = true;
|
||||||
|
for (k, v) in o {
|
||||||
|
if t.get(k) != Some(v) {
|
||||||
|
let theirs_v = t.get(k).map(|x| x.to_string()).unwrap_or_else(|| "none".into());
|
||||||
|
return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on other rules ({k} {theirs_v}, ours {v})") };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if checked {
|
||||||
|
Decision { use_it: true, verdict: "match", line: format!("Another node holds port {port} on this machine; using it (same network and rules; it is not stopped by this app)") }
|
||||||
|
} else {
|
||||||
|
Decision { use_it: true, verdict: "unknown", line: format!("Another node holds port {port} on this machine; using it. Its rules could not be checked (an older node that lacks igneum_getNodeInfo), so the app reads it as it is") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long the app waits after another node leaves its ports before starting its own (the Mac, 7 October 2026: the
|
||||||
|
/// hand node left at 00:18 UK and the app sat on "node stopped" all night, since the mode was decided once at launch).
|
||||||
|
pub const TAKEOVER_WAIT_S: f64 = 60.0;
|
||||||
|
|
||||||
|
/// What the app does about ports another node held, re-checked while attached (external) or refused (none).
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub enum Step {
|
||||||
|
/// the other node still answers on the port
|
||||||
|
Stay,
|
||||||
|
/// the port has been closed for `for_s` seconds; the app waits out TAKEOVER_WAIT_S in case it comes back
|
||||||
|
Gone { for_s: f64 },
|
||||||
|
/// the port stayed closed for TAKEOVER_WAIT_S: start our own node on the freed ports
|
||||||
|
TakeOver,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `port_open` is whether the other node's RPC port answers now, `gone_since` the app's memory of when it stopped
|
||||||
|
/// answering (None while it answers). A node that comes back inside the wait is kept; the wait starts over.
|
||||||
|
pub fn step(port_open: bool, now_s: f64, gone_since: &mut Option<f64>) -> Step {
|
||||||
|
if port_open {
|
||||||
|
*gone_since = None;
|
||||||
|
return Step::Stay;
|
||||||
|
}
|
||||||
|
let since = *gone_since.get_or_insert(now_s);
|
||||||
|
let for_s = now_s - since;
|
||||||
|
if for_s >= TAKEOVER_WAIT_S {
|
||||||
|
*gone_since = None;
|
||||||
|
Step::TakeOver
|
||||||
|
} else {
|
||||||
|
Step::Gone { for_s }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One JSON-RPC call to the node's EVM port through curl (the engine carries no HTTP client; update.rs does the same).
|
||||||
|
pub fn rpc(evm_port: u16, method: &str, params: Value, limit: Duration) -> Option<Value> {
|
||||||
|
// one path (ledger N7): execrpc holds a records-indexing method until the node's exec follower has a record
|
||||||
|
crate::execrpc::call(evm_port, method, params, limit).ok().filter(|r| !r.is_null())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The other node's answers, with what each method gives: eth_chainId (every node), igneum_getNodeInfo (newer nodes).
|
||||||
|
pub fn probe(evm_port: u16) -> Check {
|
||||||
|
let mut c = Check::default();
|
||||||
|
if let Some(Value::String(h)) = rpc(evm_port, "eth_chainId", json!([]), Duration::from_secs(4)) {
|
||||||
|
c.chain_id = u64::from_str_radix(h.trim_start_matches("0x"), 16).ok();
|
||||||
|
}
|
||||||
|
if let Some(info) = rpc(evm_port, "igneum_getNodeInfo", json!([]), Duration::from_secs(4)) {
|
||||||
|
c.digest = info.get("digest").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||||
|
c.network = info.get("network").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||||
|
c.version = info.get("version").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||||
|
c.overrides = info.get("params").cloned().filter(|v| v.is_object());
|
||||||
|
c.pow_engine = info.get("powEngine").and_then(|v| v.as_str()).map(|s| s.to_string());
|
||||||
|
c.merge_depth = info.get("blockrate").and_then(|b| b.get("mergeDepth")).and_then(|v| v.as_u64());
|
||||||
|
}
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Keep the newest `keep` files named `<prefix>...` in `dir`; the rest go (the node and miner logs grow one per start).
|
||||||
|
pub fn prune_logs(dir: &Path, prefix: &str, keep: usize) -> usize {
|
||||||
|
let Ok(rd) = std::fs::read_dir(dir) else { return 0 };
|
||||||
|
let mut files: Vec<(std::time::SystemTime, std::path::PathBuf)> = rd
|
||||||
|
.flatten()
|
||||||
|
.filter(|e| e.file_name().to_string_lossy().starts_with(prefix) && e.path().extension().map(|x| x == "log").unwrap_or(false))
|
||||||
|
.filter_map(|e| e.metadata().ok().and_then(|m| m.modified().ok()).map(|t| (t, e.path())))
|
||||||
|
.collect();
|
||||||
|
files.sort_by(|a, b| b.0.cmp(&a.0));
|
||||||
|
let mut removed = 0;
|
||||||
|
for (_, p) in files.into_iter().skip(keep) {
|
||||||
|
if std::fs::remove_file(&p).is_ok() {
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
removed
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn ours() -> Value { json!({ "difficulty_v2_activation_daa": 33000, "fees_v1_activation_daa": 210000, "exec_restart_number": 27276, "exec_restart_hash": "bb45cf0d" }) }
|
||||||
|
|
||||||
|
/// The Mac, 6 October 2026: the hand node held the ports; the app said nothing and read it as its own.
|
||||||
|
#[test]
|
||||||
|
fn a_node_on_our_rules_is_used_and_said_so() {
|
||||||
|
let theirs = Check { chain_id: Some(7_777), overrides: Some(ours()), digest: Some("1f4b".into()), ..Default::default() };
|
||||||
|
let d = decide(26611, Some(&ours()), Some(7_777), &theirs);
|
||||||
|
assert!(d.use_it);
|
||||||
|
assert_eq!(d.verdict, "match");
|
||||||
|
assert_eq!(d.line, "Another node holds port 26611 on this machine; using it (same network and rules; it is not stopped by this app)");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_node_on_another_network_or_other_rules_is_refused() {
|
||||||
|
let other_chain = Check { chain_id: Some(7_778), ..Default::default() };
|
||||||
|
let d = decide(26611, Some(&ours()), Some(7_777), &other_chain);
|
||||||
|
assert!(!d.use_it);
|
||||||
|
assert_eq!(d.verdict, "mismatch");
|
||||||
|
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)");
|
||||||
|
let mut theirs = ours(); theirs["fees_v1_activation_daa"] = json!(200000);
|
||||||
|
let d = decide(26611, Some(&ours()), None, &Check { overrides: Some(theirs), ..Default::default() });
|
||||||
|
assert!(!d.use_it);
|
||||||
|
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on other rules (fees_v1_activation_daa 200000, ours 210000)");
|
||||||
|
// the merge depth comes from blockrate.mergeDepth (compiled per network), never from params
|
||||||
|
assert_eq!(Check { merge_depth: Some(36_000), ..Default::default() }.merge_depth, Some(36_000));
|
||||||
|
// a stub engine is refused whatever else matches
|
||||||
|
assert!(!decide(26611, Some(&ours()), None, &Check { overrides: Some(ours()), pow_engine: Some("stub".into()), ..Default::default() }).use_it);
|
||||||
|
// an override we have that the other node lacks is a mismatch too
|
||||||
|
let d = decide(26611, Some(&ours()), None, &Check { overrides: Some(json!({ "difficulty_v2_activation_daa": 33000 })), ..Default::default() });
|
||||||
|
assert!(!d.use_it);
|
||||||
|
assert!(d.line.contains("exec_restart_hash none"), "{}", d.line);
|
||||||
|
// the network name (node lane, igneum_getNodeInfo.network) must be equal
|
||||||
|
let d = decide_on(26611, Some(&ours()), None, Some("testnet"), &Check { network: Some("devnet".into()), overrides: Some(ours()), ..Default::default() });
|
||||||
|
assert!(!d.use_it);
|
||||||
|
assert_eq!(d.line, "Node not started: port 26611 is taken by a node on another network (devnet, ours testnet)");
|
||||||
|
assert!(decide_on(26611, Some(&ours()), None, Some("testnet"), &Check { network: Some("testnet".into()), overrides: Some(ours()), ..Default::default() }).use_it);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_older_node_that_cannot_answer_is_used_and_marked_unknown() {
|
||||||
|
let d = decide(26611, Some(&ours()), None, &Check::default());
|
||||||
|
assert!(d.use_it);
|
||||||
|
assert_eq!(d.verdict, "unknown");
|
||||||
|
assert!(d.line.starts_with("Another node holds port 26611 on this machine; using it. Its rules could not be checked"));
|
||||||
|
// a chain id alone, matching, is a check
|
||||||
|
assert_eq!(decide(26611, None, Some(1), &Check { chain_id: Some(1), ..Default::default() }).verdict, "match");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Mac, 7 October 2026 00:18 UK: the hand node left and the app never started its own (today's app never recovers).
|
||||||
|
#[test]
|
||||||
|
fn an_external_node_that_goes_away_hands_the_ports_to_the_app_after_the_wait() {
|
||||||
|
let mut gone = None;
|
||||||
|
assert_eq!(step(true, 0.0, &mut gone), Step::Stay);
|
||||||
|
assert_eq!(gone, None);
|
||||||
|
assert_eq!(step(false, 100.0, &mut gone), Step::Gone { for_s: 0.0 });
|
||||||
|
assert_eq!(step(false, 130.0, &mut gone), Step::Gone { for_s: 30.0 });
|
||||||
|
assert_eq!(step(false, 160.0, &mut gone), Step::TakeOver);
|
||||||
|
assert_eq!(gone, None, "the memory resets once the app has its own node");
|
||||||
|
// a node that comes back inside the wait is kept, and the wait starts over
|
||||||
|
let mut gone = None;
|
||||||
|
assert_eq!(step(false, 0.0, &mut gone), Step::Gone { for_s: 0.0 });
|
||||||
|
assert_eq!(step(true, 30.0, &mut gone), Step::Stay);
|
||||||
|
assert_eq!(step(false, 40.0, &mut gone), Step::Gone { for_s: 0.0 });
|
||||||
|
assert_eq!(step(false, 99.0, &mut gone), Step::Gone { for_s: 59.0 });
|
||||||
|
assert_eq!(step(false, 100.0, &mut gone), Step::TakeOver);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prune_keeps_the_newest_logs() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("igneum-prune-test-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
for i in 0..6 {
|
||||||
|
let p = dir.join(format!("node-2026100{i}.log"));
|
||||||
|
std::fs::write(&p, "x").unwrap();
|
||||||
|
let t = std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000 + i * 60);
|
||||||
|
let f = std::fs::File::options().write(true).open(&p).unwrap();
|
||||||
|
f.set_modified(t).unwrap();
|
||||||
|
}
|
||||||
|
std::fs::write(dir.join("miner-1.log"), "y").unwrap();
|
||||||
|
std::fs::write(dir.join("node-notes.txt"), "z").unwrap();
|
||||||
|
assert_eq!(prune_logs(&dir, "node-", 4), 2);
|
||||||
|
let mut left: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect();
|
||||||
|
left.sort();
|
||||||
|
assert_eq!(left, vec!["miner-1.log", "node-20261002.log", "node-20261003.log", "node-20261004.log", "node-20261005.log", "node-notes.txt"]);
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -167,6 +167,9 @@ pub fn apply_pref(c: &mut CardState, p: &CardPref) {
|
||||||
c.mem_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_mem_mhz };
|
c.mem_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_mem_mhz };
|
||||||
c.clock_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_clock_mhz };
|
c.clock_cap_mhz = if p.pinned || p.sweep_source == "baseline" { 0 } else { p.sweep_clock_mhz };
|
||||||
c.tune_source = p.sweep_source.clone();
|
c.tune_source = p.sweep_source.clone();
|
||||||
|
c.tune_goal = p.tune_goal.clone();
|
||||||
|
c.tune_before_watts = p.sweep_before_watts;
|
||||||
|
c.tune_before_mhs = p.sweep_before_mhs;
|
||||||
if p.sweep_mhs > 0.0 && p.sweep_watts > 0.0 {
|
if p.sweep_mhs > 0.0 && p.sweep_watts > 0.0 {
|
||||||
c.tune_line = crate::ember::tuned_line(p.sweep_mhs, p.sweep_watts, p.sweep_eff);
|
c.tune_line = crate::ember::tuned_line(p.sweep_mhs, p.sweep_watts, p.sweep_eff);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
app/igneum-app/src/live.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
//! GET /api/live for the dashboard's chain scene (ui/live-dag.js, the site's module). The scene wants the network's
|
||||||
|
//! blocks over the last minutes with lanes, parents, colours, the selected chain, checkpoints and proof state. The
|
||||||
|
//! local node speaks gRPC and wRPC only and the engine carries no client for either (no TLS, no websocket stack), so
|
||||||
|
//! this reads the observer's /api/live (the same URL ota.rs already polls for the identity count) through curl, caches
|
||||||
|
//! it for 2 s, and marks this machine's blocks: the observer names a block's miner by the first 8 hex of its vote key
|
||||||
|
//! hash, and every card's `ids` carry the same 8 characters for this machine's keys, so a match becomes the lane "you".
|
||||||
|
//! Read-only; one upstream call per 2 s whatever the window asks. A local-node source is owed (a JSON RPC on igneumd or
|
||||||
|
//! a wRPC client here); the shape stays the same when it comes.
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
static CACHE: Mutex<Option<(Instant, u32, Value)>> = Mutex::new(None);
|
||||||
|
const TTL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
/// The observer's reply with this machine's lane marked. `ids` are the 8-character vote key ids of this machine's
|
||||||
|
/// cards. Blocks whose `miner` is one of them read `miner: "you"`; `state.you_blocks` counts them; `state.source` is "site"
|
||||||
|
/// (the 0.3.16 engine's live.rs answers "node" when igneumd carries igneum_getRecentBlocks). Anything else passes through untouched, so the module's contract holds.
|
||||||
|
pub fn shape(mut reply: Value, ids: &HashSet<String>) -> Value {
|
||||||
|
let mut yours = 0u64;
|
||||||
|
if let Some(blocks) = reply.get_mut("blocks").and_then(|b| b.as_array_mut()) {
|
||||||
|
for b in blocks.iter_mut() {
|
||||||
|
let mine = b.get("miner").and_then(|m| m.as_str()).map(|m| ids.contains(m)).unwrap_or(false);
|
||||||
|
if mine {
|
||||||
|
yours += 1;
|
||||||
|
if let Some(o) = b.as_object_mut() { o.insert("miner".into(), json!("you")); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(st) = reply.get_mut("state").and_then(|s| s.as_object_mut()) {
|
||||||
|
st.insert("you_blocks".into(), json!(yours));
|
||||||
|
st.insert("source".into(), json!("site"));
|
||||||
|
}
|
||||||
|
reply
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The observer URL for a window: `<live_api>?window=<s>`, the window clamped to the module's 30 to 300 s.
|
||||||
|
pub fn url_for(live_api: &str, window_s: u32) -> String {
|
||||||
|
format!("{live_api}?window={}", window_s.clamp(30, 300))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reply for the dashboard: cached 2 s per window; `{ok:false, error}` when the observer is unreachable or the
|
||||||
|
/// build has no observer address (the UI then draws its own blocks strip).
|
||||||
|
pub fn fetch(live_api: &str, window_s: u32, ids: &HashSet<String>) -> Value {
|
||||||
|
if live_api.is_empty() {
|
||||||
|
return json!({ "ok": false, "error": "no observer address in this build" });
|
||||||
|
}
|
||||||
|
let window_s = window_s.clamp(30, 300);
|
||||||
|
if let Some((at, w, v)) = CACHE.lock().unwrap().as_ref() {
|
||||||
|
if *w == window_s && at.elapsed() < TTL { return v.clone(); }
|
||||||
|
}
|
||||||
|
let url = url_for(live_api, window_s);
|
||||||
|
let out = crate::detect::run_timeout(
|
||||||
|
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "4", &url]),
|
||||||
|
None,
|
||||||
|
Duration::from_secs(6),
|
||||||
|
);
|
||||||
|
let reply = match out.and_then(|t| serde_json::from_str::<Value>(&t).ok()) {
|
||||||
|
Some(v) if v.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) && v.get("blocks").map(|b| b.is_array()).unwrap_or(false) => shape(v, ids),
|
||||||
|
_ => json!({ "ok": false, "error": "the observer did not answer" }),
|
||||||
|
};
|
||||||
|
if reply.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) {
|
||||||
|
*CACHE.lock().unwrap() = Some((Instant::now(), window_s, reply.clone()));
|
||||||
|
}
|
||||||
|
reply
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
|
||||||
|
// a fixture in the observer's shape (site/api/live.mjs): three miners, this machine is 8fafda27, one of its blocks on
|
||||||
|
// the selected chain and proven, one excluded; a locked and a pending checkpoint
|
||||||
|
fn fixture() -> Value {
|
||||||
|
json!({
|
||||||
|
"ok": true, "now": 1791301670,
|
||||||
|
"state": { "stale": false, "age_s": 1, "height": 198490 },
|
||||||
|
"blocks": [
|
||||||
|
{ "hash": "a1", "ts": 1791301600000i64, "blue_score": 194690, "daa": 198486, "parents": [], "chain": true, "color": "blue", "miner": "8fafda27", "locked": true, "final": true, "shards": [{ "state": "paid" }], "proven": true },
|
||||||
|
{ "hash": "a2", "ts": 1791301601000i64, "blue_score": 194691, "daa": 198487, "parents": ["a1"], "chain": true, "color": "blue", "miner": "1b2c3d4e", "locked": false, "final": false, "shards": [{ "state": "proving" }], "proven": false },
|
||||||
|
{ "hash": "a3", "ts": 1791301601500i64, "blue_score": null, "daa": 198487, "parents": ["a1"], "chain": false, "color": "red", "miner": "8fafda27", "locked": false, "final": false, "shards": [], "proven": false },
|
||||||
|
{ "hash": "a4", "ts": 1791301602000i64, "blue_score": 194692, "daa": 198488, "parents": ["a2", "a3"], "chain": true, "color": "pending", "miner": "deadbeef", "locked": false, "final": false, "shards": [], "proven": false }
|
||||||
|
],
|
||||||
|
"finality": { "checkpoints": [
|
||||||
|
{ "index": 6490, "blue_score": 194690, "daa": 198486, "state": "locked", "fraction_total": 0.71 },
|
||||||
|
{ "index": 6491, "blue_score": 194720, "daa": 198516, "state": "pending", "fraction_total": 0.44 }
|
||||||
|
] }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn this_machines_blocks_become_the_lane_you_and_nothing_else_moves() {
|
||||||
|
let ids: HashSet<String> = ["8fafda27".to_string(), "9a8b7c6d".to_string()].into_iter().collect();
|
||||||
|
let out = shape(fixture(), &ids);
|
||||||
|
let blocks = out["blocks"].as_array().unwrap();
|
||||||
|
assert_eq!(blocks.len(), 4);
|
||||||
|
assert_eq!(blocks[0]["miner"], "you");
|
||||||
|
assert_eq!(blocks[2]["miner"], "you");
|
||||||
|
assert_eq!(blocks[1]["miner"], "1b2c3d4e");
|
||||||
|
assert_eq!(blocks[3]["miner"], "deadbeef");
|
||||||
|
// the rest of the contract passes through: parents, chain, colour, proof state, checkpoints
|
||||||
|
assert_eq!(blocks[3]["parents"], json!(["a2", "a3"]));
|
||||||
|
assert_eq!(blocks[0]["chain"], true);
|
||||||
|
assert_eq!(blocks[2]["color"], "red");
|
||||||
|
assert_eq!(blocks[0]["proven"], true);
|
||||||
|
assert_eq!(blocks[1]["shards"][0]["state"], "proving");
|
||||||
|
assert_eq!(out["finality"]["checkpoints"].as_array().unwrap().len(), 2);
|
||||||
|
assert_eq!(out["finality"]["checkpoints"][0]["state"], "locked");
|
||||||
|
assert_eq!(out["state"]["you_blocks"], 2);
|
||||||
|
assert_eq!(out["state"]["source"], "site");
|
||||||
|
assert_eq!(out["state"]["height"], 198490);
|
||||||
|
assert_eq!(out["ok"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_ids_means_no_lane_is_yours_and_a_bare_reply_survives() {
|
||||||
|
let out = shape(fixture(), &HashSet::new());
|
||||||
|
assert!(out["blocks"].as_array().unwrap().iter().all(|b| b["miner"] != "you"));
|
||||||
|
assert_eq!(out["state"]["you_blocks"], 0);
|
||||||
|
let bare = shape(json!({ "ok": true, "blocks": [] }), &HashSet::new());
|
||||||
|
assert_eq!(bare["blocks"].as_array().unwrap().len(), 0);
|
||||||
|
assert!(bare.get("state").is_none(), "no state object is invented");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_window_is_clamped_to_the_modules_range() {
|
||||||
|
assert_eq!(url_for("https://igneum.network/api/live", 120), "https://igneum.network/api/live?window=120");
|
||||||
|
assert_eq!(url_for("https://igneum.network/api/live", 5), "https://igneum.network/api/live?window=30");
|
||||||
|
assert_eq!(url_for("https://igneum.network/api/live", 9000), "https://igneum.network/api/live?window=300");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_build_without_an_observer_address_says_so() {
|
||||||
|
let out = fetch("", 120, &HashSet::new());
|
||||||
|
assert_eq!(out["ok"], false);
|
||||||
|
assert_eq!(out["error"], "no observer address in this build");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ mod state;
|
||||||
mod manifest;
|
mod manifest;
|
||||||
mod ota;
|
mod ota;
|
||||||
mod update;
|
mod update;
|
||||||
|
mod execrpc;
|
||||||
mod jobs;
|
mod jobs;
|
||||||
mod jobrun;
|
mod jobrun;
|
||||||
mod jobbuild;
|
mod jobbuild;
|
||||||
|
|
@ -37,6 +38,12 @@ mod sweep;
|
||||||
mod ember;
|
mod ember;
|
||||||
mod powertask;
|
mod powertask;
|
||||||
mod watchdog;
|
mod watchdog;
|
||||||
|
mod live;
|
||||||
|
mod extnode;
|
||||||
|
mod merge;
|
||||||
|
mod ladder;
|
||||||
|
mod chainfacts;
|
||||||
|
mod card;
|
||||||
|
|
||||||
use std::io::{BufRead, Write};
|
use std::io::{BufRead, Write};
|
||||||
use std::sync::mpsc::channel;
|
use std::sync::mpsc::channel;
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,10 @@ pub struct Moment {
|
||||||
pub boundary_eta_s: Option<i64>,
|
pub boundary_eta_s: Option<i64>,
|
||||||
/// A worker is starting, exporting a pack or being built: let it finish.
|
/// A worker is starting, exporting a pack or being built: let it finish.
|
||||||
pub miner_busy: bool,
|
pub miner_busy: bool,
|
||||||
|
/// A remote job is running on this engine (src/jobrun.rs). It holds even an urgent install: a job is bounded by its
|
||||||
|
/// cap, and an install under it kills its process tree (PC 1, 6 October 2026, 17:52:54Z: 0.3.14 went in during a
|
||||||
|
/// measurement job because install_asked from a two-hour-old update-now still counted as urgent).
|
||||||
|
pub job_active: bool,
|
||||||
/// How long the update has been ready and waiting.
|
/// How long the update has been ready and waiting.
|
||||||
pub ready_for_s: u64,
|
pub ready_for_s: u64,
|
||||||
/// A consensus activation is close, or this version is below min_supported_version: now beats later.
|
/// A consensus activation is close, or this version is below min_supported_version: now beats later.
|
||||||
|
|
@ -320,6 +324,9 @@ pub fn slot_minute(id8: &str) -> u64 {
|
||||||
|
|
||||||
/// Ok when the update may be applied now; Err carries the reason to wait, in the words the dashboard shows.
|
/// Ok when the update may be applied now; Err carries the reason to wait, in the words the dashboard shows.
|
||||||
pub fn safe_to_apply(m: &Moment) -> Result<(), String> {
|
pub fn safe_to_apply(m: &Moment) -> Result<(), String> {
|
||||||
|
if m.job_active {
|
||||||
|
return Err("a remote job is running; installing when it closes".into());
|
||||||
|
}
|
||||||
if m.urgent {
|
if m.urgent {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
@ -492,7 +499,7 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn safe_moments() {
|
fn safe_moments() {
|
||||||
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0 };
|
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, job_active: false, ready_for_s: 60, urgent: false, slot_ok: true, network_drop_pct: 0.0 };
|
||||||
assert!(safe_to_apply(&base).is_ok());
|
assert!(safe_to_apply(&base).is_ok());
|
||||||
assert_eq!(safe_to_apply(&Moment { node_synced: false, ..base.clone() }).unwrap_err(), "waiting for the node to sync");
|
assert_eq!(safe_to_apply(&Moment { node_synced: false, ..base.clone() }).unwrap_err(), "waiting for the node to sync");
|
||||||
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(120), ..base.clone() }).unwrap_err().contains("boundary in 120 s"));
|
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(120), ..base.clone() }).unwrap_err().contains("boundary in 120 s"));
|
||||||
|
|
@ -502,6 +509,11 @@ mod tests {
|
||||||
assert!(safe_to_apply(&Moment { miner_busy: true, ..base.clone() }).unwrap_err().contains("worker"));
|
assert!(safe_to_apply(&Moment { miner_busy: true, ..base.clone() }).unwrap_err().contains("worker"));
|
||||||
// urgent beats every wait
|
// urgent beats every wait
|
||||||
assert!(safe_to_apply(&Moment { node_synced: false, boundary_eta_s: Some(5), miner_busy: true, urgent: true, ..base.clone() }).is_ok());
|
assert!(safe_to_apply(&Moment { node_synced: false, boundary_eta_s: Some(5), miner_busy: true, urgent: true, ..base.clone() }).is_ok());
|
||||||
|
// PC 1, 6 October 2026, 17:52:54Z: a scheduled update arriving mid-job is deferred to the job's close, urgent or not
|
||||||
|
assert!(safe_to_apply(&Moment { job_active: true, ..base.clone() }).unwrap_err().contains("remote job"));
|
||||||
|
assert!(safe_to_apply(&Moment { job_active: true, urgent: true, ..base.clone() }).unwrap_err().contains("remote job"));
|
||||||
|
assert!(safe_to_apply(&Moment { job_active: true, urgent: true, slot_ok: false, ..base.clone() }).is_err());
|
||||||
|
assert!(safe_to_apply(&Moment { job_active: false, urgent: true, slot_ok: false, ..base.clone() }).is_ok());
|
||||||
// patience: an unsynced node for 6 h applies anyway
|
// patience: an unsynced node for 6 h applies anyway
|
||||||
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_ok());
|
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S, ..base.clone() }).is_ok());
|
||||||
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S - 1, ..base.clone() }).is_err());
|
assert!(safe_to_apply(&Moment { node_synced: false, ready_for_s: SAFE_MOMENT_PATIENCE_S - 1, ..base.clone() }).is_err());
|
||||||
|
|
|
||||||
96
app/igneum-app/src/merge.rs
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
//! Is this node's work merging into the network? (design note, 7 October 2026, from the node lane's 30-s
|
||||||
|
//! propagation reading: a node behind a slow link keeps mining its own chain, its relayed blocks never merge, and the
|
||||||
|
//! N4 "synced" test (tip moving, a peer present) cannot see it.)
|
||||||
|
//!
|
||||||
|
//! The decision is pure. The engine feeds it our sink's blue score (the watch line's `blue=`), the sinks the network
|
||||||
|
//! reports (the observer's view, and any peer that answers igneum_getNodeInfo), the merge depth from the node's params,
|
||||||
|
//! how long ago one of our blocks was last seen in the network's view, and how long we have been mining.
|
||||||
|
|
||||||
|
/// How long our blocks may stay out of the network's view before the node reads "behind" (two minutes).
|
||||||
|
pub const NOT_MERGING_S: f64 = 120.0;
|
||||||
|
/// The merge depth when the node does not say (node lane, 7 October 2026: 1 bps x MERGE_DEPTH_DURATION = 3,600 in blue
|
||||||
|
/// score on the devnet and every 1-bps network; 36,000 at 10 bps; read from igneum_getNodeInfo's `blockrate.mergeDepth`
|
||||||
|
/// once the node carries it). Peer sinks: nothing exposes them yet; `igneum_getPeers` with lastDeliveredBlueScore is on
|
||||||
|
/// the 0.3.19 list, and the observer is the only network view until then.
|
||||||
|
pub const DEFAULT_MERGE_DEPTH: u64 = 3_600;
|
||||||
|
/// The words every surface uses for this state.
|
||||||
|
pub const NOT_MERGING: &str = "behind: your blocks are not merging into the network";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct MergeCheck {
|
||||||
|
/// our node's sink blue score
|
||||||
|
pub our_blue: u64,
|
||||||
|
/// the sinks' blue scores the network reports (observer, peers); empty = no view, no decision
|
||||||
|
pub peer_sinks: Vec<u64>,
|
||||||
|
/// the merge depth in blue score (the node's params, else DEFAULT_MERGE_DEPTH)
|
||||||
|
pub merge_depth: u64,
|
||||||
|
/// seconds since one of our blocks last appeared in the network's view; None = never seen
|
||||||
|
pub ours_seen_ago_s: Option<f64>,
|
||||||
|
/// how long this machine has been mining with a synced node, in seconds
|
||||||
|
pub mining_for_s: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when every sink the network reports is more than the merge depth ahead of ours and none of our blocks has
|
||||||
|
/// appeared in the network's view for NOT_MERGING_S (so the miner's blocks can no longer merge: hold it).
|
||||||
|
pub fn not_merging(c: &MergeCheck) -> bool {
|
||||||
|
if c.peer_sinks.is_empty() || c.mining_for_s < NOT_MERGING_S {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let depth = if c.merge_depth == 0 { DEFAULT_MERGE_DEPTH } else { c.merge_depth };
|
||||||
|
let all_ahead = c.peer_sinks.iter().all(|&s| s > c.our_blue.saturating_add(depth));
|
||||||
|
let ours_absent = c.ours_seen_ago_s.map(|a| a >= NOT_MERGING_S).unwrap_or(true);
|
||||||
|
all_ahead && ours_absent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The network's view out of an observer reply (crate::live::fetch's shape): the highest blue score it shows, and the
|
||||||
|
/// newest timestamp (unix ms) of a block it marks as ours (miner "you"). None when the reply carries no blocks.
|
||||||
|
pub fn view_of(reply: &serde_json::Value) -> Option<(u64, Option<i64>)> {
|
||||||
|
let blocks = reply.get("blocks")?.as_array()?;
|
||||||
|
if blocks.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let sink = blocks.iter().filter_map(|b| b.get("blue_score").and_then(|v| v.as_u64())).max().unwrap_or(0);
|
||||||
|
let ours = blocks.iter().filter(|b| b.get("miner").and_then(|m| m.as_str()) == Some("you")).filter_map(|b| b.get("ts").and_then(|v| v.as_i64())).max();
|
||||||
|
Some((sink, ours))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
fn c() -> MergeCheck { MergeCheck { our_blue: 100_000, peer_sinks: vec![104_000, 103_800], merge_depth: 3_600, ours_seen_ago_s: None, mining_for_s: 600.0 } }
|
||||||
|
|
||||||
|
/// The slow-link node (node lane, 30-s propagation reading): tip moving, a peer present, its own chain, nothing
|
||||||
|
/// merging. Today's sync test calls it synced; this one holds the miner.
|
||||||
|
#[test]
|
||||||
|
fn a_node_whose_blocks_never_merge_reads_behind_and_holds_the_miner() {
|
||||||
|
assert!(not_merging(&c()), "every peer more than the merge depth ahead and ours never seen");
|
||||||
|
assert!(not_merging(&MergeCheck { ours_seen_ago_s: Some(130.0), ..c() }), "ours last seen over two minutes ago");
|
||||||
|
assert_eq!(NOT_MERGING, "behind: your blocks are not merging into the network");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_merging_node_is_left_alone() {
|
||||||
|
assert!(!not_merging(&MergeCheck { ours_seen_ago_s: Some(20.0), ..c() }), "our block appeared in the network's view");
|
||||||
|
assert!(!not_merging(&MergeCheck { peer_sinks: vec![104_000, 101_000], ..c() }), "one peer within the merge depth");
|
||||||
|
assert!(!not_merging(&MergeCheck { peer_sinks: vec![103_600], ..c() }), "exactly the merge depth is not over it");
|
||||||
|
assert!(!not_merging(&MergeCheck { peer_sinks: vec![], ..c() }), "no network view, no decision");
|
||||||
|
assert!(!not_merging(&MergeCheck { mining_for_s: 30.0, ..c() }), "not mining long enough to judge");
|
||||||
|
assert!(not_merging(&MergeCheck { merge_depth: 0, peer_sinks: vec![103_601], ..c() }), "a missing depth falls back to 3,600");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_observer_reply_gives_the_sink_and_our_newest_block() {
|
||||||
|
let r = json!({ "blocks": [
|
||||||
|
{ "blue_score": 194_690, "ts": 1_791_301_600_000i64, "miner": "8fafda27" },
|
||||||
|
{ "blue_score": 194_692, "ts": 1_791_301_602_000i64, "miner": "you" },
|
||||||
|
{ "blue_score": null, "ts": 1_791_301_601_500i64, "miner": "you" },
|
||||||
|
{ "blue_score": 194_691, "ts": 1_791_301_601_000i64, "miner": "deadbeef" } ] });
|
||||||
|
assert_eq!(view_of(&r), Some((194_692, Some(1_791_301_602_000))));
|
||||||
|
let none_ours = json!({ "blocks": [{ "blue_score": 5, "ts": 1, "miner": "x" }] });
|
||||||
|
assert_eq!(view_of(&none_ours), Some((5, None)));
|
||||||
|
assert_eq!(view_of(&json!({ "blocks": [] })), None);
|
||||||
|
assert_eq!(view_of(&json!({ "ok": false })), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -70,6 +70,8 @@ pub struct Ctx {
|
||||||
pub node_synced: bool,
|
pub node_synced: bool,
|
||||||
pub boundary_eta_s: Option<i64>,
|
pub boundary_eta_s: Option<i64>,
|
||||||
pub miner_busy: bool,
|
pub miner_busy: bool,
|
||||||
|
/// A remote job is running (src/jobrun.rs): the install holds, urgent or not.
|
||||||
|
pub job_active: bool,
|
||||||
pub daa: u64,
|
pub daa: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -539,7 +541,7 @@ impl Updater {
|
||||||
}
|
}
|
||||||
let slot_ok = minute == self.slot || catch_up || std::env::var("IGNEUM_APP_UPDATE_NO_SLOT").map(|v| v == "1").unwrap_or(false);
|
let slot_ok = minute == self.slot || catch_up || std::env::var("IGNEUM_APP_UPDATE_NO_SLOT").map(|v| v == "1").unwrap_or(false);
|
||||||
let ready_for = self.ready_since.map(|t| now.duration_since(t).as_secs()).unwrap_or(0);
|
let ready_for = self.ready_since.map(|t| now.duration_since(t).as_secs()).unwrap_or(0);
|
||||||
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct };
|
let moment = Moment { node_synced: ctx.node_synced, boundary_eta_s: ctx.boundary_eta_s, miner_busy: ctx.miner_busy, job_active: ctx.job_active, ready_for_s: ready_for, urgent: urgent || self.install_asked, slot_ok, network_drop_pct };
|
||||||
if !self.auto && !urgent && !self.install_asked {
|
if !self.auto && !urgent && !self.install_asked {
|
||||||
shared.state.lock().unwrap().update.wait = "waiting for Install now (automatic updates are off)".into();
|
shared.state.lock().unwrap().update.wait = "waiting for Install now (automatic updates are off)".into();
|
||||||
return None;
|
return None;
|
||||||
|
|
@ -668,6 +670,12 @@ impl Updater {
|
||||||
shared.log(&format!("update check: {} is published but has no {} build yet", m.version, manifest::platform_name()));
|
shared.log(&format!("update check: {} is published but has no {} build yet", m.version, manifest::platform_name()));
|
||||||
} else {
|
} else {
|
||||||
shared.log(&format!("update check: {} is current (manifest {})", self.current, m.version));
|
shared.log(&format!("update check: {} is current (manifest {})", self.current, m.version));
|
||||||
|
// an asked install (update-now) covers this one check: nothing newer, so the ask is spent.
|
||||||
|
// Left true it made the NEXT manifest urgent hours later (PC 1, 6 October 2026, 17:52:54Z).
|
||||||
|
if self.install_asked {
|
||||||
|
self.install_asked = false;
|
||||||
|
shared.log("update check: Install now asked and nothing newer is published; the ask is spent (the next manifest takes the usual slot)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
self.entry = None;
|
self.entry = None;
|
||||||
self.file = None;
|
self.file = None;
|
||||||
|
|
@ -954,7 +962,7 @@ impl Updater {
|
||||||
// ---- the threads ---------------------------------------------------------------------------------------------------
|
// ---- the threads ---------------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
/// https://igneum.network/live -> https://igneum.network/api/live; "" when the build carries no live page.
|
/// https://igneum.network/live -> https://igneum.network/api/live; "" when the build carries no live page.
|
||||||
fn live_api_from(live_page: &str) -> String {
|
pub fn live_api_from(live_page: &str) -> String {
|
||||||
let Some(rest) = live_page.strip_prefix("https://") else { return String::new() };
|
let Some(rest) = live_page.strip_prefix("https://") else { return String::new() };
|
||||||
let host = rest.split('/').next().unwrap_or("");
|
let host = rest.split('/').next().unwrap_or("");
|
||||||
if host.is_empty() { String::new() } else { format!("https://{host}/api/live") }
|
if host.is_empty() { String::new() } else { format!("https://{host}/api/live") }
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,17 @@ pub fn open_url(url: &str) {
|
||||||
let _ = Command::new("xdg-open").arg(url).spawn();
|
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,
|
/// 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).
|
/// which must be refreshed (call `keep_awake_tick` every minute).
|
||||||
pub struct KeepAwake {
|
pub struct KeepAwake {
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,46 @@ use std::time::{Duration, Instant};
|
||||||
pub const TASK_NAME: &str = "Igneum Power Helper";
|
pub const TASK_NAME: &str = "Igneum Power Helper";
|
||||||
/// The helper ends after this long without a new command.
|
/// The helper ends after this long without a new command.
|
||||||
pub const IDLE_S: u64 = 20 * 60;
|
pub const IDLE_S: u64 = 20 * 60;
|
||||||
|
/// The heartbeat file the helper refreshes every poll; an engine judges "the helper runs" by it, never by a flag
|
||||||
|
/// of its own (6 October 2026, PC 1: the flag said yes after the helper's idle exit, and commands went to nobody).
|
||||||
|
pub const ALIVE_FILE: &str = "helper.alive";
|
||||||
|
/// A heartbeat older than this is a dead helper.
|
||||||
|
pub const ALIVE_MAX_S: u64 = 4;
|
||||||
|
|
||||||
|
/// Writes the heartbeat: the unix time, as text.
|
||||||
|
pub fn beat(dir: &Path, now: u64) {
|
||||||
|
let _ = std::fs::write(dir.join(ALIVE_FILE), now.to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a heartbeat: is the helper alive at `now`?
|
||||||
|
pub fn alive_at(text: &str, now: u64) -> bool {
|
||||||
|
text.trim().parse::<u64>().map(|t| now.saturating_sub(t) <= ALIVE_MAX_S).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Is the helper that reads `dir` alive now?
|
||||||
|
pub fn alive(dir: &Path) -> bool {
|
||||||
|
std::fs::read_to_string(dir.join(ALIVE_FILE)).map(|t| alive_at(&t, crate::platform::unix_now())).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the task unless the helper already runs, then waits for a fresh heartbeat (up to `wait`). The engine
|
||||||
|
/// writes a command only after this says Ok: the helper skips what the file held before its start (a stale quit
|
||||||
|
/// is not a command), so a command written before the start would be skipped with it (6 October 2026, 17:55:25Z
|
||||||
|
/// on PC 1: the first request of the first tune).
|
||||||
|
pub fn ensure_running(dir: &Path, wait: Duration) -> Result<(), String> {
|
||||||
|
if alive(dir) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let _ = std::fs::create_dir_all(dir);
|
||||||
|
start()?;
|
||||||
|
let until = Instant::now() + wait;
|
||||||
|
while Instant::now() < until {
|
||||||
|
std::thread::sleep(Duration::from_millis(250));
|
||||||
|
if alive(dir) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(format!("the Igneum Power Helper task gave no heartbeat within {} s of its start", wait.as_secs()))
|
||||||
|
}
|
||||||
|
|
||||||
/// One parsed command from cmd.txt.
|
/// One parsed command from cmd.txt.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
|
@ -79,6 +119,13 @@ pub fn parse_line(line: &str) -> Option<(u64, HelperCmd)> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The commands a helper acts on: the lines added after its start (`skip` = the line count at the start, 0 again
|
||||||
|
/// when the file shrank). A stale `quit` or `remove` from an earlier engine is never a command.
|
||||||
|
pub fn commands_after(text: &str, skip: usize) -> Vec<(u64, HelperCmd)> {
|
||||||
|
let skip = if text.lines().count() < skip { 0 } else { skip };
|
||||||
|
text.lines().skip(skip).filter_map(parse_line).collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// The nvidia-smi arguments a command becomes (None for the verbs that run nothing).
|
/// The nvidia-smi arguments a command becomes (None for the verbs that run nothing).
|
||||||
pub fn smi_args(dev: &str, c: &HelperCmd) -> Option<Vec<String>> {
|
pub fn smi_args(dev: &str, c: &HelperCmd) -> Option<Vec<String>> {
|
||||||
match c {
|
match c {
|
||||||
|
|
@ -195,20 +242,30 @@ pub fn run_helper(dir: &Path) -> i32 {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
log("helper started (scheduled task, elevated)");
|
log("helper started (scheduled task, elevated)");
|
||||||
// a stale file from an earlier run is not a command: only lines after the start count
|
beat(dir, crate::platform::unix_now());
|
||||||
let mut last_seq: u64 = std::fs::read_to_string(&cmd_file).ok().and_then(|t| t.lines().filter_map(parse_line).map(|(s, _)| s).max()).unwrap_or(0);
|
// a stale file from an earlier run is not a command: only lines ADDED after the start count. 6 October 2026,
|
||||||
let mut last_text = String::new();
|
// PC 1 17:55:55Z: a helper that started after an engine had written `quit` read that line and exited in the
|
||||||
|
// same second, and every later start did the same; so the lines present at the start are skipped whole
|
||||||
|
// (quit and remove included), and a file that shrinks starts the count again
|
||||||
|
let initial = std::fs::read_to_string(&cmd_file).unwrap_or_default();
|
||||||
|
let mut last_seq: u64 = initial.lines().filter_map(parse_line).map(|(s, _)| s).max().unwrap_or(0);
|
||||||
|
let mut skip = initial.lines().count();
|
||||||
|
let mut last_text = initial;
|
||||||
let mut dev = "0".to_string();
|
let mut dev = "0".to_string();
|
||||||
let mut idle = Instant::now();
|
let mut idle = Instant::now();
|
||||||
let smi = crate::platform::tool("nvidia-smi");
|
let smi = crate::platform::tool("nvidia-smi");
|
||||||
loop {
|
loop {
|
||||||
let text = std::fs::read_to_string(&cmd_file).unwrap_or_default();
|
let text = std::fs::read_to_string(&cmd_file).unwrap_or_default();
|
||||||
if text != last_text {
|
if text != last_text {
|
||||||
|
if text.lines().count() < skip {
|
||||||
|
skip = 0;
|
||||||
|
}
|
||||||
last_text = text.clone();
|
last_text = text.clone();
|
||||||
for (seq, c) in text.lines().filter_map(parse_line) {
|
for (seq, c) in commands_after(&text, skip) {
|
||||||
match c {
|
match c {
|
||||||
HelperCmd::Quit => {
|
HelperCmd::Quit => {
|
||||||
log("quit");
|
log("quit");
|
||||||
|
let _ = std::fs::remove_file(dir.join(ALIVE_FILE));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
HelperCmd::Remove => {
|
HelperCmd::Remove => {
|
||||||
|
|
@ -261,8 +318,10 @@ pub fn run_helper(dir: &Path) -> i32 {
|
||||||
}
|
}
|
||||||
if idle.elapsed() >= Duration::from_secs(IDLE_S) {
|
if idle.elapsed() >= Duration::from_secs(IDLE_S) {
|
||||||
log("idle 20 min: exit (the engine starts the task again when it needs it)");
|
log("idle 20 min: exit (the engine starts the task again when it needs it)");
|
||||||
|
let _ = std::fs::remove_file(dir.join(ALIVE_FILE));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
beat(dir, crate::platform::unix_now());
|
||||||
std::thread::sleep(Duration::from_millis(500));
|
std::thread::sleep(Duration::from_millis(500));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -296,6 +355,15 @@ mod tests {
|
||||||
assert_eq!(parse_line("quit"), Some((0, HelperCmd::Quit)));
|
assert_eq!(parse_line("quit"), Some((0, HelperCmd::Quit)));
|
||||||
assert_eq!(parse_line("remove"), Some((0, HelperCmd::Remove)));
|
assert_eq!(parse_line("remove"), Some((0, HelperCmd::Remove)));
|
||||||
assert_eq!(parse_line("12 reregister"), Some((12, HelperCmd::Reregister)));
|
assert_eq!(parse_line("12 reregister"), Some((12, HelperCmd::Reregister)));
|
||||||
|
// a stale quit present at the start is skipped; a quit added later counts; a rewritten (shorter) file counts whole
|
||||||
|
assert_eq!(commands_after("5 dev 0\n6 pl 460\nquit\n", 3), vec![]);
|
||||||
|
assert_eq!(commands_after("5 dev 0\n6 pl 460\nquit\n7 dev 1\n", 3), vec![(7, HelperCmd::Dev("1".into()))]);
|
||||||
|
assert_eq!(commands_after("quit\n", 3), vec![(0, HelperCmd::Quit)]);
|
||||||
|
// the heartbeat: fresh within ALIVE_MAX_S, dead after, dead when unreadable
|
||||||
|
assert!(alive_at("1791309325", 1791309325 + ALIVE_MAX_S));
|
||||||
|
assert!(!alive_at("1791309325", 1791309325 + ALIVE_MAX_S + 1));
|
||||||
|
assert!(!alive_at("", 1791309325));
|
||||||
|
assert!(!alive_at("soon", 1791309325));
|
||||||
assert_eq!(parse_line("12 reregister C:\\evil.exe"), None, "no path argument: the helper picks the install folder itself");
|
assert_eq!(parse_line("12 reregister C:\\evil.exe"), None, "no path argument: the helper picks the install folder itself");
|
||||||
assert_eq!(smi_args("0", &HelperCmd::Reregister), None);
|
assert_eq!(smi_args("0", &HelperCmd::Reregister), None);
|
||||||
assert!(readback_command().contains("Actions[0].Execute"));
|
assert!(readback_command().contains("Actions[0].Execute"));
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,22 @@ struct Tools {
|
||||||
/// The node's re-derivation boundary (0.3.14, 6 October 2026): the chain block its EVM restarted at after the
|
/// The node's re-derivation boundary (0.3.14, 6 October 2026): the chain block its EVM restarted at after the
|
||||||
/// bodies below the pruning point were gone (`igneum_getExecStatus.restartNumber`; on a 0.3.13 node the
|
/// bodies below the pruning point were gone (`igneum_getExecStatus.restartNumber`; on a 0.3.13 node the
|
||||||
/// `startedFrom` text "restart at chain block N"), else 0. The prover never claims work below it (no bodies, no
|
/// `startedFrom` text "restart at chain block N"), else 0. The prover never claims work below it (no bodies, no
|
||||||
|
/// The exporter's failure line. When the node's export carries the account dump (0.3.14) and the exporter's output
|
||||||
|
/// never mentions it, the exporter at `path` predates 0.3.14: it replays the dump's own segment from the restart
|
||||||
|
/// state and fails there with "port state root differs" (PC 1, 6 October 2026 18:16Z, block 140,662: the installer
|
||||||
|
/// had not replaced igneum-prove-export); the line names the stale binary instead of the misleading root error.
|
||||||
|
fn exporter_failure(export_has_dump: bool, out: &str, path: &Path, block: Option<u64>) -> String {
|
||||||
|
let last = out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed");
|
||||||
|
let what = match block {
|
||||||
|
Some(b) => format!("exporter, block {b}"),
|
||||||
|
None => "exporter".to_string(),
|
||||||
|
};
|
||||||
|
if export_has_dump && !out.contains("account dump") {
|
||||||
|
return format!("{what}: stale igneum-prove-export at {}: the node's export carries the account dump and this exporter does not read it (it predates 0.3.14); install this release's exporter there ({last})", path.display());
|
||||||
|
}
|
||||||
|
format!("{what}: {last}")
|
||||||
|
}
|
||||||
|
|
||||||
/// state: unprovable on every node).
|
/// state: unprovable on every node).
|
||||||
fn exec_boundary(shared: &Shared) -> u64 {
|
fn exec_boundary(shared: &Shared) -> u64 {
|
||||||
let st = match evm_rpc(shared, "igneum_getExecStatus", json!([]), Duration::from_secs(5)) {
|
let st = match evm_rpc(shared, "igneum_getExecStatus", json!([]), Duration::from_secs(5)) {
|
||||||
|
|
@ -152,22 +168,8 @@ fn exec_boundary(shared: &Shared) -> u64 {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
|
fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
|
||||||
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
|
// one path (ledger N7): execrpc holds a records-indexing method until the node's exec follower has a record
|
||||||
let tmp = std::env::temp_dir().join(format!("igneum-prover-{}-{}.json", std::process::id(), method));
|
crate::execrpc::call(shared.runtime.evm_port(), method, params, timeout)
|
||||||
std::fs::write(&tmp, body).map_err(|e| e.to_string())?;
|
|
||||||
let url = format!("http://127.0.0.1:{}", shared.runtime.evm_port());
|
|
||||||
let out = crate::detect::run_timeout(
|
|
||||||
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", &timeout.as_secs().to_string(), "-X", "POST", &url, "-H", "Content-Type: application/json", "--data-binary", &format!("@{}", tmp.display())]),
|
|
||||||
None,
|
|
||||||
timeout + Duration::from_secs(2),
|
|
||||||
);
|
|
||||||
let _ = std::fs::remove_file(&tmp);
|
|
||||||
let out = out.ok_or_else(|| format!("{method}: the node's RPC did not answer"))?;
|
|
||||||
let v: Value = serde_json::from_str(&out).map_err(|e| format!("{method}: {e}"))?;
|
|
||||||
if let Some(err) = v.get("error") {
|
|
||||||
return Err(format!("{method}: {}", err.get("message").and_then(|m| m.as_str()).unwrap_or("error")));
|
|
||||||
}
|
|
||||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn find_tools(bin_dir: &Path) -> Result<Tools, String> {
|
fn find_tools(bin_dir: &Path) -> Result<Tools, String> {
|
||||||
|
|
@ -462,6 +464,16 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// ledger N7 (7 October 2026, PC 1 on 0.3.18): a node before the exec RPC fix dies on igneum_getAssignedShards
|
||||||
|
// and igneum_getProofRecords when its exec follower holds no record (rpc.rs slices records[1..=0]), and the
|
||||||
|
// node reads synced seconds before a slow follower has one; nothing below asks until the follower reports a tip
|
||||||
|
if !crate::update::exec_has_record(shared.runtime.evm_port()) {
|
||||||
|
set(&shared, |p| {
|
||||||
|
p.status = "waiting".into();
|
||||||
|
p.message = "waiting for the node's execution layer".into();
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// 1. the keys and the work list
|
// 1. the keys and the work list
|
||||||
let labels = labels(&shared);
|
let labels = labels(&shared);
|
||||||
let mut keys: Vec<(String, String)> = Vec::new();
|
let mut keys: Vec<(String, String)> = Vec::new();
|
||||||
|
|
@ -499,6 +511,7 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
||||||
if paid {
|
if paid {
|
||||||
submitted.retain(|x| !(x.1 == h && x.2 == s));
|
submitted.retain(|x| !(x.1 == h && x.2 == s));
|
||||||
shared.event("proving", &format!("block {n} shard {s} paid {} IGN", wei as f64 / 1e18));
|
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| {
|
set(&shared, |p| {
|
||||||
p.paid += 1;
|
p.paid += 1;
|
||||||
p.paid_wei += wei;
|
p.paid_wei += wei;
|
||||||
|
|
@ -665,7 +678,7 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
||||||
let (seq_p, fix_p, res_p) = if t.wsl { (wsl_path(&seq), wsl_path(&fixture), wsl_path(&results)) } else { (seq.display().to_string(), fixture.display().to_string(), results.display().to_string()) };
|
let (seq_p, fix_p, res_p) = if t.wsl { (wsl_path(&seq), wsl_path(&fixture), wsl_path(&results)) } else { (seq.display().to_string(), fixture.display().to_string(), results.display().to_string()) };
|
||||||
let (ok, out) = run_tool(&shared, t, &t.export, &[seq_p, w.number.to_string(), fix_p.clone()], &[], Duration::from_secs(600), &dir.join(format!("export-{}.log", w.number)));
|
let (ok, out) = run_tool(&shared, t, &t.export, &[seq_p, w.number.to_string(), fix_p.clone()], &[], Duration::from_secs(600), &dir.join(format!("export-{}.log", w.number)));
|
||||||
if !ok || !fixture.exists() {
|
if !ok || !fixture.exists() {
|
||||||
return Err(format!("exporter: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
|
return Err(exporter_failure(export["preState"].is_array(), &out, &t.export, None));
|
||||||
}
|
}
|
||||||
set(&shared, |p| p.message = if t.cuda { "proving on the GPU".into() } else { "CPU prover: about five minutes a shard, 30 GB of RAM, paid only when no card proves first".into() });
|
set(&shared, |p| p.message = if t.cuda { "proving on the GPU".into() } else { "CPU prover: about five minutes a shard, 30 GB of RAM, paid only when no card proves first".into() });
|
||||||
let prover_env = if t.cuda { "cuda" } else { "cpu" };
|
let prover_env = if t.cuda { "cuda" } else { "cpu" };
|
||||||
|
|
@ -854,7 +867,7 @@ fn prove_segment(shared: &Shared, t: &Tools, seg: &crate::segments::SegmentWork,
|
||||||
let fixture = dir.join(format!("block-{b}.json"));
|
let fixture = dir.join(format!("block-{b}.json"));
|
||||||
let (ok, out) = run_tool(shared, t, &t.export, &[as_host_path(&seq), b.to_string(), as_host_path(&fixture)], &[], Duration::from_secs(600), &dir.join(format!("export-{b}.log")));
|
let (ok, out) = run_tool(shared, t, &t.export, &[as_host_path(&seq), b.to_string(), as_host_path(&fixture)], &[], Duration::from_secs(600), &dir.join(format!("export-{b}.log")));
|
||||||
if !ok || !fixture.exists() {
|
if !ok || !fixture.exists() {
|
||||||
return Err(format!("exporter, block {b}: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
|
return Err(exporter_failure(export["preState"].is_array(), &out, &t.export, Some(b)));
|
||||||
}
|
}
|
||||||
fixtures.push(as_host_path(&fixture));
|
fixtures.push(as_host_path(&fixture));
|
||||||
}
|
}
|
||||||
|
|
@ -1092,6 +1105,21 @@ pub fn setup(shared: &Shared) -> Result<Value, String> {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_stale_exporter_is_named_when_the_export_carries_the_dump() {
|
||||||
|
let p = Path::new("C:/x/igneum-prove-export.exe");
|
||||||
|
let old_out = "Error: segment 140661: port state root 0xe45c differs from the node's 0xddd7; the port is not the node's executor, stop\n";
|
||||||
|
let line = exporter_failure(true, old_out, p, Some(140662));
|
||||||
|
assert!(line.contains("stale igneum-prove-export at C:/x/igneum-prove-export.exe"), "{line}");
|
||||||
|
assert!(line.starts_with("exporter, block 140662:"));
|
||||||
|
// the 0.3.14 exporter read the dump and failed later: the real line, not the stale one
|
||||||
|
let new_out = "account dump: 79 accounts after chain block 140661, state root 0x1 (equals the node's)\nError: segment 140662: port state root 0x2 differs from the node's 0x3\n";
|
||||||
|
let line = exporter_failure(true, new_out, p, None);
|
||||||
|
assert_eq!(line, "exporter: Error: segment 140662: port state root 0x2 differs from the node's 0x3");
|
||||||
|
// an export without a dump (an older node): never the stale line
|
||||||
|
assert_eq!(exporter_failure(false, old_out, p, None), format!("exporter: {}", old_out.trim()));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pinned_ids_come_out_of_the_hosts_id_line() {
|
fn pinned_ids_come_out_of_the_hosts_id_line() {
|
||||||
let out = "RESULT id: pinned guests: shard program id 0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a (2832504 bytes, sha256 0x150f4c05a2951fc5) aggregator id 0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896 (319744 bytes), pinned 2026-10-05T16:20:38Z on Darwin, SP1 5.0 circuit v5\n";
|
let out = "RESULT id: pinned guests: shard program id 0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a (2832504 bytes, sha256 0x150f4c05a2951fc5) aggregator id 0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896 (319744 bytes), pinned 2026-10-05T16:20:38Z on Darwin, SP1 5.0 circuit v5\n";
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ const INDEX: &str = include_str!("../ui/index.html");
|
||||||
const CSS: &str = include_str!("../ui/app.css");
|
const CSS: &str = include_str!("../ui/app.css");
|
||||||
const JS: &str = include_str!("../ui/app.js");
|
const JS: &str = include_str!("../ui/app.js");
|
||||||
const MARK: &str = include_str!("../ui/mark.svg");
|
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 COIN: &[u8] = include_bytes!("../../../brand/igneum-coin-1024.png");
|
||||||
const FONT_MONO_400: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-400.woff2");
|
const FONT_MONO_400: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-400.woff2");
|
||||||
const FONT_MONO_500: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-500.woff2");
|
const FONT_MONO_500: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-500.woff2");
|
||||||
|
|
@ -138,6 +141,15 @@ fn json_resp(stream: &mut TcpStream, status: u16, v: Value) {
|
||||||
respond(stream, status, "application/json; charset=utf-8", v.to_string().as_bytes(), false);
|
respond(stream, status, "application/json; charset=utf-8", v.to_string().as_bytes(), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Where a screenshot lands: `<data root>/shots/igneum-<name>-<unix>.png`, the name reduced to [a-z0-9-] (at most
|
||||||
|
/// 24 characters, "shot" when empty) so a caller cannot steer the host outside the folder.
|
||||||
|
pub fn shot_path(data_root: &std::path::Path, name: &str, unix: u64) -> std::path::PathBuf {
|
||||||
|
let safe: String = name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').map(|c| c.to_ascii_lowercase()).take(24).collect();
|
||||||
|
let safe = if safe.is_empty() { "shot".to_string() } else { safe };
|
||||||
|
data_root.join("shots").join(format!("igneum-{safe}-{unix}.png"))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
fn query_param(q: &str, key: &str) -> Option<String> {
|
fn query_param(q: &str, key: &str) -> Option<String> {
|
||||||
q.split('&').find_map(|kv| {
|
q.split('&').find_map(|kv| {
|
||||||
let (k, v) = kv.split_once('=')?;
|
let (k, v) = kv.split_once('=')?;
|
||||||
|
|
@ -165,6 +177,8 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
|
||||||
("GET", "/app.css") => respond(&mut stream, 200, "text/css; charset=utf-8", CSS.as_bytes(), false),
|
("GET", "/app.css") => respond(&mut stream, 200, "text/css; charset=utf-8", CSS.as_bytes(), false),
|
||||||
("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false),
|
("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", "/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", "/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-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),
|
("GET", "/fonts/IBMPlexMono-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_500, true),
|
||||||
|
|
@ -178,6 +192,20 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
|
||||||
let v = shared.state_json();
|
let v = shared.state_json();
|
||||||
json_resp(&mut stream, 200, v);
|
json_resp(&mut stream, 200, v);
|
||||||
}
|
}
|
||||||
|
// the chain scene's feed (src/live.rs): the observer's /api/live with this machine's lane as "you", cached 2 s
|
||||||
|
("GET", "/api/live") => {
|
||||||
|
let window = query_param(&req.query, "window").and_then(|s| s.parse().ok()).unwrap_or(120u32);
|
||||||
|
let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page));
|
||||||
|
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") => {
|
("GET", "/api/log") => {
|
||||||
let after = query_param(&req.query, "after").and_then(|s| s.parse().ok()).unwrap_or(0u64);
|
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);
|
let limit = query_param(&req.query, "limit").and_then(|s| s.parse().ok()).unwrap_or(400usize).min(2000);
|
||||||
|
|
@ -256,6 +284,14 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
||||||
let goal = body.get("goal").and_then(|v| v.as_str()).map(|g| crate::ember::Goal::parse(g).name().to_string());
|
let goal = body.get("goal").and_then(|v| v.as_str()).map(|g| crate::ember::Goal::parse(g).name().to_string());
|
||||||
let price = body.get("price_pence").and_then(|v| v.as_f64()).filter(|p| (0.0..=500.0).contains(p));
|
let price = body.get("price_pence").and_then(|v| v.as_f64()).filter(|p| (0.0..=500.0).contains(p));
|
||||||
let climb = body.get("climb").and_then(|v| v.as_bool());
|
let climb = body.get("climb").and_then(|v| v.as_bool());
|
||||||
|
// with "key": that card's goal override ("" or "global" clears it); without: the global goal
|
||||||
|
let key = body.get("key").and_then(|v| v.as_str()).map(|k| k.to_string());
|
||||||
|
if let Some(k) = key {
|
||||||
|
let g = body.get("goal").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
let g = if g.is_empty() || g == "global" { String::new() } else { crate::ember::Goal::parse(g).name().to_string() };
|
||||||
|
shared.send(Cmd::TuneCardGoal(k, g));
|
||||||
|
return Ok(json!({ "ok": true }));
|
||||||
|
}
|
||||||
shared.send(Cmd::TuneGoal(goal, price, climb));
|
shared.send(Cmd::TuneGoal(goal, price, climb));
|
||||||
Ok(json!({ "ok": true }))
|
Ok(json!({ "ok": true }))
|
||||||
}
|
}
|
||||||
|
|
@ -267,7 +303,8 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
||||||
let display_name = s("display_name");
|
let display_name = s("display_name");
|
||||||
let dev_fee = body.get("dev_fee").and_then(|v| v.as_bool());
|
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());
|
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" => shared.set_prove(body.get("on").and_then(|v| v.as_bool()).unwrap_or(false)),
|
||||||
"/api/prove/setup" => crate::prover::setup(shared),
|
"/api/prove/setup" => crate::prover::setup(shared),
|
||||||
|
|
@ -351,6 +388,29 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
||||||
shared.send(Cmd::ClockCheck);
|
shared.send(Cmd::ClockCheck);
|
||||||
Ok(json!({ "ok": true }))
|
Ok(json!({ "ok": true }))
|
||||||
}
|
}
|
||||||
|
// Miner UI 4 (b): a screenshot from the machine itself. The host (app/mac, app/windows) owns the window, so the
|
||||||
|
// 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());
|
||||||
|
if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); }
|
||||||
|
println!("SHOT {}", path.display());
|
||||||
|
Ok(json!({ "ok": true, "path": path.display().to_string(), "note": "the window host writes the PNG within a few seconds; a bare engine without a host writes nothing" }))
|
||||||
|
}
|
||||||
"/api/quit" => {
|
"/api/quit" => {
|
||||||
// the caller is on 127.0.0.1 and holds the token: the installer, the OTA apply, a script that read app.url
|
// the caller is on 127.0.0.1 and holds the token: the installer, the OTA apply, a script that read app.url
|
||||||
shared.send(Cmd::Quit("POST /api/quit (a local caller with the token: the installer, the OTA apply, or a script that read app.url)"));
|
shared.send(Cmd::Quit("POST /api/quit (a local caller with the token: the installer, the OTA apply, or a script that read app.url)"));
|
||||||
|
|
@ -359,3 +419,17 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
||||||
_ => Err("unknown api".into()),
|
_ => Err("unknown api".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn a_shot_lands_under_the_data_root_with_a_safe_name() {
|
||||||
|
let root = std::path::Path::new("/tmp/igneum-data");
|
||||||
|
assert_eq!(shot_path(root, "overview", 1791327000), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-overview-1791327000.png"));
|
||||||
|
assert_eq!(shot_path(root, "../../etc/passwd", 1), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-etcpasswd-1.png"));
|
||||||
|
assert_eq!(shot_path(root, "", 2), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-shot-2.png"));
|
||||||
|
assert_eq!(shot_path(root, "Cards Light!", 3), std::path::PathBuf::from("/tmp/igneum-data/shots/igneum-cardslight-3.png"));
|
||||||
|
assert!(shot_path(root, &"x".repeat(80), 4).file_name().unwrap().to_string_lossy().len() < 48);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,29 @@ pub struct NodeState {
|
||||||
pub version: String,
|
pub version: String,
|
||||||
pub last_reading_age_s: f64,
|
pub last_reading_age_s: f64,
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
/// N4 (6 October 2026, the frozen tip): seconds since the sink block's header time, from the watch line's
|
||||||
|
/// tip_age_s (the node lane, ca3-v4-0316); -1 until the node reports it. "synced" needs it at or under 120.
|
||||||
|
pub tip_age_s: f64,
|
||||||
|
/// whose node this is (publish 2's read-back, 6 October 2026): "app" (started by this app), "external" (another
|
||||||
|
/// node on this machine holds the ports; used after a check), "none" (the ports are taken by a node on other rules
|
||||||
|
/// or another network, so no node is read), "" before the first start
|
||||||
|
pub source: String,
|
||||||
|
/// for an external node: match | mismatch | unknown (an older node that cannot answer the check)
|
||||||
|
pub rules_check: String,
|
||||||
|
/// the one line that says what happened at the ports, kept for the Node details
|
||||||
|
pub port_note: String,
|
||||||
|
/// where consensus_digest came from: "rpc" (igneum_getNodeInfo), "log" (the node's own stdout line), ""
|
||||||
|
pub digest_source: String,
|
||||||
|
/// which node this app reads: "own" (it started one), "external" (another node on this machine holds the ports),
|
||||||
|
/// "none" (the ports are taken by a node it refuses), "" before the first decision. Re-decided when the other
|
||||||
|
/// node goes away (7 October 2026): after 60 s the app starts its own on the freed ports.
|
||||||
|
pub mode: String,
|
||||||
|
/// why the mode is what it is, one line
|
||||||
|
pub mode_reason: String,
|
||||||
|
/// why the node is not "synced", in plain words ("" when synced): "behind" | "no peers" | "syncing" | "frozen"
|
||||||
|
pub sync_cause: String,
|
||||||
|
/// the miner's stall exits (code 45, "STALLED") since the node last started; the second one restarts the node
|
||||||
|
pub stall_exits: u32,
|
||||||
/// a consensus switch the signed manifest announced (difficulty v2 activation DAA); 0 = none
|
/// a consensus switch the signed manifest announced (difficulty v2 activation DAA); 0 = none
|
||||||
pub consensus_switch_daa: u64,
|
pub consensus_switch_daa: u64,
|
||||||
pub override_restart_wait: String,
|
pub override_restart_wait: String,
|
||||||
|
|
@ -126,6 +149,9 @@ pub struct CardState {
|
||||||
pub tune_clock_mhz: u32, // the clock cap the last tune chose (0 = unlocked)
|
pub tune_clock_mhz: u32, // the clock cap the last tune chose (0 = unlocked)
|
||||||
pub tune_source: String, // full | confirm | baseline
|
pub tune_source: String, // full | confirm | baseline
|
||||||
pub tune_line: String, // "Tuned: 122.3 MH/s at 290 W (0.422 MH/W)" once tuned
|
pub tune_line: String, // "Tuned: 122.3 MH/s at 290 W (0.422 MH/W)" once tuned
|
||||||
|
pub tune_goal: String, // this card's goal override (efficiency | balanced | rate); "" = the global goal
|
||||||
|
pub tune_before_watts: f64, // the untuned point of the last full plan (step 0); 0 = never measured
|
||||||
|
pub tune_before_mhs: f64,
|
||||||
// a tune in progress on this card, by this engine or by a measurement engine posting /api/tune-progress
|
// a tune in progress on this card, by this engine or by a measurement engine posting /api/tune-progress
|
||||||
// (the project lead, 6 October 2026: "don't we need to show in the app that tuning is in progress?")
|
// (the project lead, 6 October 2026: "don't we need to show in the app that tuning is in progress?")
|
||||||
pub tune_step: u32,
|
pub tune_step: u32,
|
||||||
|
|
@ -304,6 +330,8 @@ pub struct SettingsState {
|
||||||
pub dev_fee: bool,
|
pub dev_fee: bool,
|
||||||
/// devnet only: the node trusts proof records without a verifier (`IGNEUM_PROOF_VERIFY=trust`)
|
/// devnet only: the node trusts proof records without a verifier (`IGNEUM_PROOF_VERIFY=trust`)
|
||||||
pub proof_verify_trust: bool,
|
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.
|
/// One remote job this machine ran (the ledger entry), for the Settings history and the last-job strip.
|
||||||
|
|
@ -421,6 +449,8 @@ pub struct State {
|
||||||
pub now: f64,
|
pub now: f64,
|
||||||
pub quitting: bool,
|
pub quitting: bool,
|
||||||
pub live_page: String,
|
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.
|
/// Ring buffers behind the state: events (newest first in the JSON) and the log drawer.
|
||||||
|
|
|
||||||
|
|
@ -40,22 +40,37 @@ fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||||
era * 146_097 + doe - 719_468
|
era * 146_097 + doe - 719_468
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True once the node's exec follower holds a record (igneum_getExecStatus's executedTipHash is set). Any error or an
|
||||||
|
/// unreachable node reads false: the block sample waits rather than asks.
|
||||||
|
pub fn exec_has_record(evm_port: u16) -> bool {
|
||||||
|
crate::execrpc::has_record(evm_port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The reading of an igneum_getExecStatus reply: a record is held when executedTipHash is a non-null string.
|
||||||
|
pub fn exec_status_has_record(reply: &str) -> bool {
|
||||||
|
serde_json::from_str::<serde_json::Value>(reply).ok().map(|v| crate::execrpc::status_has_record(v.get("result").unwrap_or(&serde_json::Value::Null))).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// The latest block's timestamp (unix seconds) from the node's Ethereum JSON-RPC (the execution layer mirrors the
|
/// The latest block's timestamp (unix seconds) from the node's Ethereum JSON-RPC (the execution layer mirrors the
|
||||||
/// consensus block times). The first clock source: local time against what the peers produced.
|
/// consensus block times). The first clock source: local time against what the peers produced.
|
||||||
pub fn latest_block_time(evm_port: u16) -> Option<f64> {
|
pub fn latest_block_time(evm_port: u16) -> Option<f64> {
|
||||||
let body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}";
|
// 0.3.18/0.3.19 (ledger N7): through the one gate, which asks nothing of a follower without a record
|
||||||
let out = crate::detect::run_timeout(
|
let block = crate::execrpc::call(evm_port, "eth_getBlockByNumber", serde_json::json!(["latest", false]), Duration::from_secs(5)).ok()?;
|
||||||
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "5", "-X", "POST", &format!("http://127.0.0.1:{evm_port}"), "-H", "Content-Type: application/json", "-d", body]),
|
let ts = block.get("timestamp")?.as_str()?;
|
||||||
None,
|
|
||||||
Duration::from_secs(7),
|
|
||||||
)?;
|
|
||||||
let v: serde_json::Value = serde_json::from_str(&out).ok()?;
|
|
||||||
let ts = v.get("result")?.get("timestamp")?.as_str()?;
|
|
||||||
u64::from_str_radix(ts.trim_start_matches("0x"), 16).ok().map(|t| t as f64)
|
u64::from_str_radix(ts.trim_start_matches("0x"), 16).ok().map(|t| t as f64)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
/// Ledger N7: the clock sample must not ask eth_getBlockByNumber of a follower with no record
|
||||||
|
#[test]
|
||||||
|
fn exec_status_gate() {
|
||||||
|
assert!(super::exec_status_has_record(r#"{"jsonrpc":"2.0","id":1,"result":{"executedTip":"0x38a5","executedTipHash":"0xa3ae37ec31b9227c3855a1b25ba50ee9f6815bf00b0befcc8836b430baf04343"}}"#));
|
||||||
|
assert!(!super::exec_status_has_record(r#"{"jsonrpc":"2.0","id":1,"result":{"executedTip":"0x0","executedTipHash":null}}"#));
|
||||||
|
assert!(!super::exec_status_has_record(r#"{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}"#));
|
||||||
|
assert!(!super::exec_status_has_record(""));
|
||||||
|
assert!(!super::exec_status_has_record("not json"));
|
||||||
|
}
|
||||||
#[test]
|
#[test]
|
||||||
fn http_date() {
|
fn http_date() {
|
||||||
assert_eq!(super::parse_http_date("Sun, 04 Oct 2026 11:17:47 GMT"), Some(1_791_112_667.0));
|
assert_eq!(super::parse_http_date("Sun, 04 Oct 2026 11:17:47 GMT"), Some(1_791_112_667.0));
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,11 @@
|
||||||
|
|
||||||
/// No status line from a running miner for this long: restart it.
|
/// No status line from a running miner for this long: restart it.
|
||||||
pub const NO_STATUS_S: f64 = 90.0;
|
pub const NO_STATUS_S: f64 = 90.0;
|
||||||
|
/// A worker that gives no status within this many seconds of its start, with the node synced, is one restart then
|
||||||
|
/// faulted (PC 1, 7 October 2026 04:51Z: the row asks for a fault line at 60 s).
|
||||||
|
pub const START_S: f64 = 60.0;
|
||||||
|
/// The start of a fault reason the node's readiness, not the card, explains: released when the node syncs.
|
||||||
|
const NODE_FAULTS: [&str; 2] = ["no status line from the miner", "the worker gave no status within"];
|
||||||
/// Hash rate 0 while the node is synced and the worker is ready for this long: restart the miner.
|
/// Hash rate 0 while the node is synced and the worker is ready for this long: restart the miner.
|
||||||
pub const ZERO_RATE_S: f64 = 60.0;
|
pub const ZERO_RATE_S: f64 = 60.0;
|
||||||
/// The miner is restarting its own worker: the app waits this long for `ready` before it steps in.
|
/// The miner is restarting its own worker: the app waits this long for `ready` before it steps in.
|
||||||
|
|
@ -86,6 +91,14 @@ pub enum Event<'a> {
|
||||||
Stopped,
|
Stopped,
|
||||||
/// The user changed the card's settings: a faulted card may try again.
|
/// The user changed the card's settings: a faulted card may try again.
|
||||||
Reset,
|
Reset,
|
||||||
|
/// The node became synced: a card faulted for silence while the node was not ready tries again (PC 1, 7 October
|
||||||
|
/// 2026 04:51Z: every card faulted "no status line" during the post-relaunch sync and never came back).
|
||||||
|
NodeSynced,
|
||||||
|
/// The miner printed "template fetch timed out": it is alive and the node is not answering templates (PC 1,
|
||||||
|
/// 7 October 2026 04:51Z: the node reported synced from the first second while its finality replay blocked the
|
||||||
|
/// template RPC for three minutes; with no template the miner prints no status line). Silence is not the card's
|
||||||
|
/// fault while this goes on; the miner is judged again once templates flow.
|
||||||
|
TemplateTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
|
@ -109,6 +122,15 @@ pub struct CardWatch {
|
||||||
restarts: u32,
|
restarts: u32,
|
||||||
faulted: Option<String>,
|
faulted: Option<String>,
|
||||||
last_fault: String,
|
last_fault: String,
|
||||||
|
/// the miner's last line was a template timeout (the node not answering), cleared by the next status line
|
||||||
|
templates_blocked: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CardWatch {
|
||||||
|
/// True while the miner's last word was a template timeout: the node, not the card, holds the hashing.
|
||||||
|
pub fn templates_blocked(&self) -> bool {
|
||||||
|
self.templates_blocked
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CardWatch {
|
impl CardWatch {
|
||||||
|
|
@ -162,6 +184,7 @@ impl CardWatch {
|
||||||
Action::None
|
Action::None
|
||||||
}
|
}
|
||||||
Event::Status(s) => {
|
Event::Status(s) => {
|
||||||
|
self.templates_blocked = false;
|
||||||
self.last_status_s = Some(now_s);
|
self.last_status_s = Some(now_s);
|
||||||
if s.hash_now > 0.0 {
|
if s.hash_now > 0.0 {
|
||||||
self.zero_since = None;
|
self.zero_since = None;
|
||||||
|
|
@ -207,9 +230,30 @@ impl CardWatch {
|
||||||
*self = Self::default();
|
*self = Self::default();
|
||||||
Action::None
|
Action::None
|
||||||
}
|
}
|
||||||
|
Event::TemplateTimeout => {
|
||||||
|
// the miner's own heartbeat: every silence clock starts over from this line
|
||||||
|
self.started_s = Some(now_s);
|
||||||
|
if self.last_status_s.is_some() {
|
||||||
|
self.last_status_s = Some(now_s);
|
||||||
|
}
|
||||||
|
self.zero_since = None;
|
||||||
|
self.templates_blocked = true;
|
||||||
|
Action::None
|
||||||
|
}
|
||||||
|
Event::NodeSynced => {
|
||||||
|
if self.faulted.as_deref().map(|f| NODE_FAULTS.iter().any(|p| f.starts_with(p))).unwrap_or(false) {
|
||||||
|
*self = Self::default();
|
||||||
|
}
|
||||||
|
Action::None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when the card is faulted for a reason the node's readiness explains (released by Event::NodeSynced).
|
||||||
|
pub fn faulted_by_node(&self) -> bool {
|
||||||
|
self.faulted.as_deref().map(|f| NODE_FAULTS.iter().any(|p| f.starts_with(p))).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
/// Called every engine tick while the miner process is alive.
|
/// Called every engine tick while the miner process is alive.
|
||||||
pub fn tick(&mut self, now_s: f64, node_synced: bool) -> Action {
|
pub fn tick(&mut self, now_s: f64, node_synced: bool) -> Action {
|
||||||
if self.faulted.is_some() {
|
if self.faulted.is_some() {
|
||||||
|
|
@ -223,14 +267,23 @@ impl CardWatch {
|
||||||
}
|
}
|
||||||
return Action::None;
|
return Action::None;
|
||||||
}
|
}
|
||||||
|
if !node_synced {
|
||||||
|
// a miner is judged only while the node is synced: with no template it cannot print status, so the silence
|
||||||
|
// clocks (start, no status, zero rate) hold at now until the node is back (PC 1, 7 October 2026)
|
||||||
|
self.started_s = Some(now_s);
|
||||||
|
if self.last_status_s.is_some() {
|
||||||
|
self.last_status_s = Some(now_s);
|
||||||
|
}
|
||||||
|
self.zero_since = None;
|
||||||
|
return Action::None;
|
||||||
|
}
|
||||||
|
if !self.ready && self.last_status_s.is_none() && now_s - started > START_S {
|
||||||
|
return self.escalate(format!("the worker gave no status within {} s of starting", START_S as u64));
|
||||||
|
}
|
||||||
let last = self.last_status_s.unwrap_or(started);
|
let last = self.last_status_s.unwrap_or(started);
|
||||||
if now_s - last > NO_STATUS_S {
|
if now_s - last > NO_STATUS_S {
|
||||||
return self.escalate(format!("no status line from the miner for {} s", NO_STATUS_S as u64));
|
return self.escalate(format!("no status line from the miner for {} s", NO_STATUS_S as u64));
|
||||||
}
|
}
|
||||||
if !node_synced {
|
|
||||||
// a zero rate while the node syncs is expected; the timer starts again once it is synced
|
|
||||||
self.zero_since = None;
|
|
||||||
}
|
|
||||||
if node_synced && self.ready {
|
if node_synced && self.ready {
|
||||||
if let Some(z) = self.zero_since {
|
if let Some(z) = self.zero_since {
|
||||||
if now_s - z >= ZERO_RATE_S {
|
if now_s - z >= ZERO_RATE_S {
|
||||||
|
|
@ -488,6 +541,70 @@ mod tests {
|
||||||
assert!(matches!(w.tick(240.0, true), Action::Restart(_)));
|
assert!(matches!(w.tick(240.0, true), Action::Restart(_)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PC 1, 7 October 2026 04:51:43Z (docs/plans/release-0.3.17.md, the PC 1 row): the app relaunched after the OTA
|
||||||
|
/// apply, started its workers while the node was still syncing, and every card ended "faulted: no status line from
|
||||||
|
/// the miner for 90 s (restarted once already)", pid 0, 0.0 MH/s from then on. Today's watchdog clocks the silence
|
||||||
|
/// while the node is not synced and never releases the fault.
|
||||||
|
#[test]
|
||||||
|
fn a_worker_started_while_the_node_syncs_is_not_faulted_and_a_faulted_one_returns_at_sync() {
|
||||||
|
// the recorded sequence (relaunch-engine-and-workers.txt): node synced=true from the first second, the miner
|
||||||
|
// started at +14 s, subscribed, then every template fetch timed out at 5 s for 180 s; no status line was printed
|
||||||
|
let mut r = CardWatch::new();
|
||||||
|
r.event(14.0, Event::Started);
|
||||||
|
let mut t = 19.0;
|
||||||
|
while t <= 200.0 {
|
||||||
|
r.event(t, Event::TemplateTimeout);
|
||||||
|
assert_eq!(r.tick(t + 1.0, true), Action::None, "at {t}: a miner whose template fetches time out is not faulted on silence");
|
||||||
|
assert!(r.templates_blocked());
|
||||||
|
t += 5.0;
|
||||||
|
}
|
||||||
|
// the node answers templates again: the miner prints status and is healthy
|
||||||
|
healthy(&mut r, 210.0, 400.0);
|
||||||
|
assert!(!r.templates_blocked());
|
||||||
|
assert_eq!(r.faulted(), None);
|
||||||
|
// a miner that stops printing even timeouts is judged again from its last line
|
||||||
|
let mut q = CardWatch::new();
|
||||||
|
q.event(0.0, Event::Started);
|
||||||
|
q.event(50.0, Event::TemplateTimeout);
|
||||||
|
assert_eq!(q.tick(109.0, true), Action::None);
|
||||||
|
assert!(matches!(q.tick(111.0, true), Action::Restart(_)), "60 s of nothing at all after the last timeout line");
|
||||||
|
|
||||||
|
let mut w = CardWatch::new();
|
||||||
|
w.event(0.0, Event::Started);
|
||||||
|
// the node reads unsynced for 200 s (the relaunch's sync): no verdict at all
|
||||||
|
let mut t = 5.0;
|
||||||
|
while t <= 200.0 {
|
||||||
|
assert_eq!(w.tick(t, false), Action::None, "at {t}: a miner is judged only while the node is synced");
|
||||||
|
t += 10.0;
|
||||||
|
}
|
||||||
|
// the node syncs; the worker answers inside 60 s and is healthy
|
||||||
|
assert_eq!(w.tick(210.0, true), Action::None);
|
||||||
|
w.event(230.0, Event::Ready);
|
||||||
|
healthy(&mut w, 240.0, 400.0);
|
||||||
|
assert_eq!(w.faulted(), None);
|
||||||
|
// the recorded state: a card faulted for silence while the node was not ready
|
||||||
|
let mut f = CardWatch::new();
|
||||||
|
f.event(0.0, Event::Started);
|
||||||
|
assert!(matches!(f.tick(100.0, true), Action::Restart(_)));
|
||||||
|
f.event(101.0, Event::Started);
|
||||||
|
let a = f.tick(200.0, true);
|
||||||
|
assert!(matches!(a, Action::Fault(ref r) if (r.starts_with("no status line from the miner") || r.starts_with("the worker gave no status within")) && r.contains("restarted once already")), "{a:?}");
|
||||||
|
assert!(f.faulted_by_node());
|
||||||
|
f.event(300.0, Event::NodeSynced);
|
||||||
|
assert_eq!(f.faulted(), None, "the node syncing releases a silence fault");
|
||||||
|
assert_eq!(f.watchdog_restarts(), 0);
|
||||||
|
// a fault the card owns (zero rate while synced) stays through NodeSynced
|
||||||
|
let mut z = CardWatch::new();
|
||||||
|
z.event(0.0, Event::Started);
|
||||||
|
z.event(1.0, Event::Ready);
|
||||||
|
let zero = parse_status(STATUS_OK.replace("now=124.10 MH/s", "now=0.00 MH/s").as_str()).unwrap();
|
||||||
|
for t in [10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0] { z.event(t, Event::Status(&zero)); z.tick(t, true); }
|
||||||
|
assert!(z.faulted().is_none() || !z.faulted_by_node());
|
||||||
|
let before = z.faulted().map(|s| s.to_string());
|
||||||
|
z.event(500.0, Event::NodeSynced);
|
||||||
|
assert_eq!(z.faulted().map(|s| s.to_string()), before);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_status_for_90s_restarts() {
|
fn no_status_for_90s_restarts() {
|
||||||
let mut w = CardWatch::new();
|
let mut w = CardWatch::new();
|
||||||
|
|
@ -502,10 +619,11 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_status_from_the_start() {
|
fn no_status_from_the_start() {
|
||||||
|
// a worker silent from its start, with the node synced, is judged at START_S (60 s), not NO_STATUS_S
|
||||||
let mut w = CardWatch::new();
|
let mut w = CardWatch::new();
|
||||||
w.event(0.0, Event::Started);
|
w.event(0.0, Event::Started);
|
||||||
assert_eq!(w.tick(89.0, true), Action::None);
|
assert_eq!(w.tick(59.0, true), Action::None);
|
||||||
assert!(matches!(w.tick(91.0, true), Action::Restart(_)));
|
assert!(matches!(w.tick(61.0, true), Action::Restart(ref r) if r.contains("gave no status within 60 s")));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,15 @@
|
||||||
--s-1:4px;--s-2:8px;--s-3:12px;--s-4:16px;--s-5:24px;--s-6:32px;
|
--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);
|
--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;
|
--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"]){
|
@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;
|
--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"]{
|
: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;
|
--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}
|
*{box-sizing:border-box}
|
||||||
html,body{height:100%}
|
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}
|
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}
|
||||||
|
|
@ -61,7 +63,7 @@ input,textarea{font-variant-numeric:tabular-nums}
|
||||||
.btn.tiny{min-height:26px;padding:2px 10px;font-size:var(--t-sm);border-radius:7px;font-family:var(--mono);font-weight:500}
|
.btn.tiny{min-height:26px;padding:2px 10px;font-size:var(--t-sm);border-radius:7px;font-family:var(--mono);font-weight:500}
|
||||||
.btn.ghost{border-color:transparent;color:var(--ink-2)}
|
.btn.ghost{border-color:transparent;color:var(--ink-2)}
|
||||||
.btn.ghost:hover{border-color:var(--line-2);color:var(--bone)}
|
.btn.ghost:hover{border-color:var(--line-2);color:var(--bone)}
|
||||||
.btn.ghost.on{color:var(--molten);border-color:var(--molten-40);background:var(--molten-10)}
|
.btn.ghost.on{color:var(--molten);border-color:var(--molten-40)}
|
||||||
.btn.danger:hover{color:var(--ember);border-color:var(--ember)}
|
.btn.danger:hover{color:var(--ember);border-color:var(--ember)}
|
||||||
:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
|
:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
|
||||||
@media (prefers-reduced-motion:reduce){.btn{transition:none}}
|
@media (prefers-reduced-motion:reduce){.btn{transition:none}}
|
||||||
|
|
@ -77,7 +79,7 @@ body.mac .rail-brand{padding-top:30px}
|
||||||
.nav svg{width:19px;height:19px;flex:0 0 19px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round;color:var(--ash);transition:color .15s ease}
|
.nav svg{width:19px;height:19px;flex:0 0 19px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round;color:var(--ash);transition:color .15s ease}
|
||||||
.nav:hover{background:var(--hover);color:var(--bone)}
|
.nav:hover{background:var(--hover);color:var(--bone)}
|
||||||
.nav:hover svg{color:var(--ink-2)}
|
.nav:hover svg{color:var(--ink-2)}
|
||||||
.nav.on{background:var(--ember-12);border-color:var(--ember-40);color:var(--bone);font-weight:600}
|
.nav.on{background:var(--hover);border-color:var(--line);color:var(--bone);font-weight:600}
|
||||||
.nav.on svg{color:var(--ember)}
|
.nav.on svg{color:var(--ember)}
|
||||||
.nav.on::before{content:"";position:absolute;left:-13px;top:10px;bottom:10px;width:3px;border-radius:0 3px 3px 0;background:var(--ember)}
|
.nav.on::before{content:"";position:absolute;left:-13px;top:10px;bottom:10px;width:3px;border-radius:0 3px 3px 0;background:var(--ember)}
|
||||||
.nav.small{min-height:36px;font-size:var(--t-base);color:var(--ash)}
|
.nav.small{min-height:36px;font-size:var(--t-base);color:var(--ash)}
|
||||||
|
|
@ -99,13 +101,15 @@ body.has-rail .top{left:var(--rail)}
|
||||||
.brand{display:flex;align-items:center;gap:10px;min-width:0}
|
.brand{display:flex;align-items:center;gap:10px;min-width:0}
|
||||||
.brand .word{font-family:var(--head);font-weight:900;font-size:20px;letter-spacing:.06em}
|
.brand .word{font-family:var(--head);font-weight:900;font-size:20px;letter-spacing:.06em}
|
||||||
.brand .miner{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.22em;color:var(--ash);margin-left:var(--s-1);padding-top:3px}
|
.brand .miner{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.22em;color:var(--ash);margin-left:var(--s-1);padding-top:3px}
|
||||||
|
body.has-rail .top{align-items:baseline;padding-top:19px}
|
||||||
.page-title{display:flex;align-items:baseline;gap:12px;min-width:0}
|
.page-title{display:flex;align-items:baseline;gap:12px;min-width:0}
|
||||||
.page-title h1{font-size:19px;font-weight:700;letter-spacing:0;white-space:nowrap}
|
.page-title h1{font-size:19px;font-weight:700;letter-spacing:0;white-space:nowrap}
|
||||||
.page-sub{font-size:var(--t-base);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
.page-sub{font-size:var(--t-base);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
||||||
.top-right{display:flex;align-items:center;gap:var(--s-3);flex:0 0 auto;min-width:0}
|
.top-right{display:flex;align-items:center;gap:var(--s-3);flex:0 0 auto;min-width:0}
|
||||||
.pill{display:inline-flex;align-items:center;gap:var(--s-2);font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:6px 12px 6px 10px;background:var(--graphite);white-space:nowrap;font-variant-numeric:tabular-nums;min-width:112px;justify-content:center}
|
.pill{display:inline-flex;align-items:baseline;gap:var(--s-2);font-family:var(--sans);font-size:var(--t-base);font-weight:600;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:5px 12px 5px 10px;background:var(--graphite);white-space:nowrap;font-variant-numeric:tabular-nums;justify-content:center;line-height:1.3}
|
||||||
|
.pill .dot{position:relative;top:-1px}
|
||||||
.pill.on{color:var(--molten);border-color:var(--molten-40)}
|
.pill.on{color:var(--molten);border-color:var(--molten-40)}
|
||||||
.pill.warn{color:var(--ember)}
|
.pill.warn{color:var(--ember);border-color:var(--ember-40)}
|
||||||
.dot{width:8px;height:8px;border-radius:50%;background:var(--ash);display:inline-block;flex:0 0 8px}
|
.dot{width:8px;height:8px;border-radius:50%;background:var(--ash);display:inline-block;flex:0 0 8px}
|
||||||
.on .dot,.dot.live{background:var(--molten);animation:pulse 2s ease-in-out infinite}
|
.on .dot,.dot.live{background:var(--molten);animation:pulse 2s ease-in-out infinite}
|
||||||
.warn .dot,.dot.bad{background:var(--ember);animation:none}
|
.warn .dot,.dot.bad{background:var(--ember);animation:none}
|
||||||
|
|
@ -116,26 +120,30 @@ body.has-rail .top{left:var(--rail)}
|
||||||
top moves once per change with a 150 ms transition (layoutStrip), never per poll. */
|
top moves once per change with a 150 ms transition (layoutStrip), never per poll. */
|
||||||
.notices{position:fixed;top:var(--top);left:0;right:0;z-index:19}
|
.notices{position:fixed;top:var(--top);left:0;right:0;z-index:19}
|
||||||
body.has-rail .notices{left:var(--rail)}
|
body.has-rail .notices{left:var(--rail)}
|
||||||
.notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-4);padding:8px calc(var(--gutter) - 6px) 8px var(--gutter);background:var(--molten-10);border-bottom:1px solid var(--molten-40);font-size:var(--t-base);line-height:1.4;color:var(--bone)}
|
.notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-3);padding:9px calc(var(--gutter) - 6px) 9px var(--gutter);background:var(--row);border-bottom:1px solid var(--line);box-shadow:inset 0 1px 0 var(--ember);font-size:var(--t-base);line-height:1.4;color:var(--ash)}
|
||||||
.notice.bad{background:var(--ember-12);border-bottom-color:var(--ember-40)}
|
.notice-dot{width:7px;height:7px;border-radius:50%;background:var(--ember);flex:0 0 7px;display:inline-block}
|
||||||
.notice.warn{background:var(--molten-10);border-bottom-color:var(--molten-40)}
|
.notice.bad .notice-text,.notice.update-urgent .notice-text{color:var(--bone)}
|
||||||
.notice.update-urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600}
|
.notice.update-urgent .notice-text{font-weight:600}
|
||||||
|
.notice .notice-text b{color:var(--bone);font-weight:600}
|
||||||
.notice-text{flex:1 1 320px;min-width:0}
|
.notice-text{flex:1 1 320px;min-width:0}
|
||||||
.notice-actions{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto}
|
.notice-actions{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto}
|
||||||
.notice-actions:empty{display:none}
|
.notice-actions:empty{display:none}
|
||||||
.notice-close{flex:0 0 auto;width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--ash);font-size:20px;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}
|
.notice-close{flex:0 0 auto;width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--ash);font-size:20px;line-height:1;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}
|
||||||
.notice-close:hover{color:var(--bone);border-color:var(--line-2)}
|
.notice-close:hover{color:var(--bone);border-color:var(--line-2)}
|
||||||
.notice.update-urgent .notice-close{color:#fff}
|
.notice-more{flex:0 0 auto;width:30px;height:30px;border-radius:8px;border:1px solid transparent;background:transparent;color:var(--ash);cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0}
|
||||||
.notice-detail{flex-basis:100%;font-size:var(--t-sm);color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:-3px}
|
.notice-more:hover{color:var(--bone);border-color:var(--line-2)}
|
||||||
.notice .prog{flex-basis:100%;height:3px;background:rgba(127,127,127,.2);border-radius:2px;overflow:hidden;margin-top:-3px}
|
.notice-more .chev{width:8px;height:8px;transform:rotate(45deg) translateY(-2px)}
|
||||||
|
.notice-more.open .chev{transform:rotate(225deg) translateY(-2px)}
|
||||||
|
.notice-detail{flex-basis:100%;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);white-space:normal;word-break:break-word;margin-top:-2px;user-select:text;-webkit-user-select:text}
|
||||||
|
.notice .prog{flex-basis:100%;height:2px;background:var(--line);border-radius:2px;overflow:hidden;margin-top:-3px}
|
||||||
.notice .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear}
|
.notice .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear}
|
||||||
|
|
||||||
/* a question in place of a dialog: the quit strip over the page, the inline asks inside a card */
|
/* a question in place of a dialog: the quit strip over the page, the inline asks inside a card */
|
||||||
.ask-wrap{position:fixed;left:0;right:0;bottom:0;z-index:36;display:flex;justify-content:center;padding:0 var(--s-4) var(--s-4);pointer-events:none}
|
.ask-wrap{position:fixed;left:0;right:0;bottom:0;z-index:36;display:flex;justify-content:center;padding:0 var(--s-4) var(--s-4);pointer-events:none}
|
||||||
body.has-rail .ask-wrap{left:var(--rail)}
|
body.has-rail .ask-wrap{left:var(--rail)}
|
||||||
.ask{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;background:var(--graphite);border:1px solid var(--ember-40);border-radius:14px;padding:12px 16px;box-shadow:0 14px 40px var(--shadow);pointer-events:auto;max-width:720px;animation:rise .2s ease}
|
.ask{display:flex;align-items:center;gap:var(--s-3);flex-wrap:wrap;background:var(--row);border:1px solid var(--line);border-radius:14px;padding:12px 16px;box-shadow:inset 0 1px 0 var(--ember),0 14px 40px var(--shadow);pointer-events:auto;max-width:720px;animation:rise .2s ease}
|
||||||
.ask-text{flex:1 1 260px;font-size:var(--t-md);min-width:0}
|
.ask-text{flex:1 1 260px;font-size:var(--t-md);min-width:0}
|
||||||
.ask.inline{box-shadow:none;background:var(--ember-12);margin-top:var(--s-3);animation:none}
|
.ask.inline{box-shadow:inset 0 1px 0 var(--ember);background:var(--row);margin-top:var(--s-3);animation:none}
|
||||||
|
|
||||||
/* screens and pages */
|
/* screens and pages */
|
||||||
main{position:absolute;top:var(--top);bottom:var(--bottom);left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain;transition:top .15s ease}
|
main{position:absolute;top:var(--top);bottom:var(--bottom);left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain;transition:top .15s ease}
|
||||||
|
|
@ -214,7 +222,7 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
|
||||||
.toggle-big .ts{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.06em;opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
.toggle-big .ts{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.06em;opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
||||||
.toggle-big.stop{background:var(--graphite);border-color:var(--line-2);color:var(--bone)}
|
.toggle-big.stop{background:var(--graphite);border-color:var(--line-2);color:var(--bone)}
|
||||||
.toggle-big.stop:hover{border-color:var(--ash);background:var(--row)}
|
.toggle-big.stop:hover{border-color:var(--ash);background:var(--row)}
|
||||||
.toggle-big.stop .ring{background:var(--ember-12);color:var(--ember)}
|
.toggle-big.stop .ring{background:var(--obsidian);border:1px solid var(--line-2);color:var(--ember)}
|
||||||
.toggle-big.stop .ts{color:var(--molten);opacity:1}
|
.toggle-big.stop .ts{color:var(--molten);opacity:1}
|
||||||
.totals{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--s-3);background:var(--graphite);border:1px solid var(--line);border-radius:14px;padding:18px 20px;min-width:0}
|
.totals{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--s-3);background:var(--graphite);border:1px solid var(--line);border-radius:14px;padding:18px 20px;min-width:0}
|
||||||
.tot{display:flex;flex-direction:column;min-width:0;padding-left:var(--s-4);border-left:1px solid var(--line)}
|
.tot{display:flex;flex-direction:column;min-width:0;padding-left:var(--s-4);border-left:1px solid var(--line)}
|
||||||
|
|
@ -246,9 +254,10 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
|
||||||
.feed>div:last-child{border-bottom:0}
|
.feed>div:last-child{border-bottom:0}
|
||||||
.feed>div>span:first-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.feed>div>span:first-child{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
.feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)}
|
.feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)}
|
||||||
.feed .k{color:var(--molten);margin-right:6px}
|
.feed .k{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--molten);margin-right:10px;position:relative;top:-1px}
|
||||||
.feed .k.error,.feed .k.warn,.feed .k.block{color:var(--ember)}
|
.feed .k.error,.feed .k.warn,.feed .k.block{background:var(--ember)}
|
||||||
.feed .k.build,.feed .k.info{color:var(--ash)}
|
.feed .k.build,.feed .k.info{background:var(--line-2)}
|
||||||
|
.feed .k.proving{background:var(--nvidia)}
|
||||||
|
|
||||||
/* the card rows on Mine (the hero object): badge, name and state, the numbers, Tune, the switch, the chevron;
|
/* the card rows on Mine (the hero object): badge, name and state, the numbers, Tune, the switch, the chevron;
|
||||||
the tune line under; the details under that */
|
the tune line under; the details under that */
|
||||||
|
|
@ -349,8 +358,8 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
|
||||||
#s-jobs-history td{padding:6px 6px 6px 0;border-top:1px solid var(--line);vertical-align:top}
|
#s-jobs-history td{padding:6px 6px 6px 0;border-top:1px solid var(--line);vertical-align:top}
|
||||||
#s-jobs-history tr.failed td,#s-jobs-history tr.timeout td,#s-jobs-history tr.aborted td{color:var(--ember)}
|
#s-jobs-history tr.failed td,#s-jobs-history tr.timeout td,#s-jobs-history tr.aborted td{color:var(--ember)}
|
||||||
#s-jobs-history tr.running td{color:var(--molten)}
|
#s-jobs-history tr.running td{color:var(--molten)}
|
||||||
.clock-card{border:1px solid var(--ember-40);background:var(--ember-12);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:var(--s-2)}
|
.clock-card{border:1px solid var(--line);background:var(--row);box-shadow:inset 0 1px 0 var(--ember);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:var(--s-2)}
|
||||||
.clock-card.warn{border-color:var(--molten-40);background:var(--molten-10)}
|
.clock-card.warn{box-shadow:inset 0 1px 0 var(--molten)}
|
||||||
.clock-msg{font-size:var(--t-md);color:var(--bone);line-height:1.45}
|
.clock-msg{font-size:var(--t-md);color:var(--bone);line-height:1.45}
|
||||||
.clock-card .note{margin-top:0}
|
.clock-card .note{margin-top:0}
|
||||||
|
|
||||||
|
|
@ -360,6 +369,8 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
|
||||||
.money-row:last-child{border-bottom:0}
|
.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{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.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}
|
.money-row .s{font-size:var(--t-base);color:var(--ash);min-width:0}
|
||||||
|
|
||||||
/* prove */
|
/* prove */
|
||||||
|
|
@ -372,7 +383,7 @@ table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
|
||||||
.options{display:flex;flex-direction:column;gap:var(--s-3);margin-top:6px}
|
.options{display:flex;flex-direction:column;gap:var(--s-3);margin-top:6px}
|
||||||
.option{display:flex;gap:var(--s-4);align-items:flex-start;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:20px 22px;cursor:pointer;transition:border-color .15s ease,background .15s ease}
|
.option{display:flex;gap:var(--s-4);align-items:flex-start;background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:20px 22px;cursor:pointer;transition:border-color .15s ease,background .15s ease}
|
||||||
.option:hover{border-color:var(--line-2)}
|
.option:hover{border-color:var(--line-2)}
|
||||||
.option.on{border-color:var(--ember);background:var(--ember-12)}
|
.option.on{border-color:var(--ember);background:var(--row)}
|
||||||
.option input[type=radio]{position:absolute;opacity:0;width:0;height:0}
|
.option input[type=radio]{position:absolute;opacity:0;width:0;height:0}
|
||||||
.option .radio{width:20px;height:20px;border-radius:50%;border:2px solid var(--line-2);flex:0 0 20px;margin-top:2px;position:relative}
|
.option .radio{width:20px;height:20px;border-radius:50%;border:2px solid var(--line-2);flex:0 0 20px;margin-top:2px;position:relative}
|
||||||
.option.on .radio{border-color:var(--ember)}
|
.option.on .radio{border-color:var(--ember)}
|
||||||
|
|
@ -506,8 +517,8 @@ body.drawer-drag main{pointer-events:none}
|
||||||
.log .src.app{color:var(--ember)}
|
.log .src.app{color:var(--ember)}
|
||||||
.log .src.watch{color:var(--ash)}
|
.log .src.watch{color:var(--ash)}
|
||||||
.log .e,.log .e .src{color:var(--ember)}
|
.log .e,.log .e .src{color:var(--ember)}
|
||||||
.log .ln.hit{background:rgba(255,179,92,.18);box-shadow:inset 3px 0 0 var(--molten);margin:0 calc(-1 * var(--gutter));padding:0 var(--gutter)}
|
.log .ln.hit{background:var(--hover);box-shadow:inset 3px 0 0 var(--molten);margin:0 calc(-1 * var(--gutter));padding:0 var(--gutter)}
|
||||||
.log mark{background:rgba(255,179,92,.3);color:var(--bone);border-radius:2px;padding:0 1px}
|
.log mark{background:transparent;color:var(--bone);text-decoration:underline;text-decoration-color:var(--ember);text-underline-offset:2px}
|
||||||
.log-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--ash);font-size:var(--t-sm);padding:0 var(--gutter);text-align:center}
|
.log-empty{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:var(--ash);font-size:var(--t-sm);padding:0 var(--gutter);text-align:center}
|
||||||
.log-jump{position:absolute;right:calc(var(--gutter) + 14px);bottom:14px;background:var(--graphite);border-color:var(--molten-40);color:var(--molten);box-shadow:0 8px 24px var(--shadow);z-index:2;gap:6px;padding:6px 12px}
|
.log-jump{position:absolute;right:calc(var(--gutter) + 14px);bottom:14px;background:var(--graphite);border-color:var(--molten-40);color:var(--molten);box-shadow:0 8px 24px var(--shadow);z-index:2;gap:6px;padding:6px 12px}
|
||||||
.log-jump:hover{border-color:var(--molten)}
|
.log-jump:hover{border-color:var(--molten)}
|
||||||
|
|
@ -590,6 +601,8 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
|
||||||
.step{padding:32px 0}
|
.step{padding:32px 0}
|
||||||
.money-row .v{font-size:var(--t-num)}
|
.money-row .v{font-size:var(--t-num)}
|
||||||
.drawer-head{padding-left:var(--s-4);padding-right:var(--s-4)}
|
.drawer-head{padding-left:var(--s-4);padding-right:var(--s-4)}
|
||||||
|
.notice{padding-left:var(--s-4);padding-right:var(--s-2);gap:var(--s-2)}
|
||||||
|
.notice-text{flex-basis:160px}
|
||||||
.log,.log-ruler .t0,.log-ruler .t1{padding-left:var(--s-4);padding-right:var(--s-4)}
|
.log,.log-ruler .t0,.log-ruler .t1{padding-left:var(--s-4);padding-right:var(--s-4)}
|
||||||
}
|
}
|
||||||
/* short windows (the 600 px floor): tighter paddings, a smaller canvas, so the drawer always has room */
|
/* short windows (the 600 px floor): tighter paddings, a smaller canvas, so the drawer always has room */
|
||||||
|
|
@ -605,3 +618,175 @@ body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
|
||||||
.drawer-head{padding-bottom:6px}
|
.drawer-head{padding-bottom:6px}
|
||||||
.toggle-big{min-height:96px}
|
.toggle-big{min-height:96px}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* miner-ui-4 (6 October 2026): Overview and Cards. The Overview hero (the project lead's pick, C): the fleet rate at 84 px on a
|
||||||
|
graphite-to-obsidian fade with W, £ a day and blocks beside it, Start/Stop as the full-width bar under it. */
|
||||||
|
.hero-row{grid-template-columns:1fr}
|
||||||
|
.totals{order:1;grid-template-columns:2fr 1fr 1fr 1fr;align-items:end;padding:28px 28px 24px;border-color:transparent;background:linear-gradient(180deg,var(--graphite),var(--obsidian))}
|
||||||
|
.tot{border-left:0;padding-left:0}
|
||||||
|
.tot:first-child .v{font-size:84px;line-height:.95;letter-spacing:-.03em}
|
||||||
|
.tot:first-child .k{font-size:var(--t-sm);margin-top:10px}
|
||||||
|
.tot:first-child .s{font-size:var(--t-md)}
|
||||||
|
.tot .v{font-size:30px}
|
||||||
|
.tot .v.ask-price{font-size:var(--t-md);padding:9px 0 8px}
|
||||||
|
.toggle-big{order:2;min-height:64px;flex-direction:row;align-items:center;gap:14px;padding:12px 20px}
|
||||||
|
.toggle-big .ring{margin:0}
|
||||||
|
.toggle-big .ts{margin-left:auto}
|
||||||
|
/* the chain scene: the site's live-dag.js in a 220 px box, the legend words under it, the blocks strip as the fallback */
|
||||||
|
.dag-wrap{position:relative}
|
||||||
|
#dag-live{display:block;width:100%;height:220px;border-radius:10px;background:var(--obsidian);border:1px solid var(--line)}
|
||||||
|
#dag{height:120px;margin-bottom:0}
|
||||||
|
.dag-tip{position:absolute;left:0;top:0;pointer-events:none;background:var(--graphite);border:1px solid var(--line-2);border-radius:8px;padding:8px 10px;font-size:var(--t-xs);color:var(--ink-2);display:flex;flex-direction:column;gap:2px;max-width:320px;box-shadow:0 8px 24px var(--shadow);z-index:3}
|
||||||
|
.dag-tip b{color:var(--bone);font-weight:500;word-break:break-all}
|
||||||
|
.dag-chip{position:absolute;right:10px;top:10px;font-size:var(--t-xs);color:var(--ash);background:var(--graphite);border:1px solid var(--line-2);border-radius:999px;padding:3px 10px;pointer-events:none;z-index:2}
|
||||||
|
.legend{display:flex;flex-wrap:wrap;gap:8px 16px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ash);align-items:center}
|
||||||
|
.legend span{display:inline-flex;align-items:center;gap:7px}
|
||||||
|
.lg{width:10px;height:10px;border-radius:3px;display:inline-block;border:1.5px solid var(--ash);background:var(--row)}
|
||||||
|
.lg.you{border-color:var(--molten);box-shadow:0 0 0 2px var(--molten-40)}
|
||||||
|
.lg.pending{border-color:var(--ash)}
|
||||||
|
.lg.included{border-color:var(--ember)}
|
||||||
|
.lg.excluded{border-color:var(--ash);opacity:.45}
|
||||||
|
.lg.proven{background:var(--ember);border-color:var(--ember)}
|
||||||
|
.lg.locked{border-color:var(--ember);border-radius:50%;box-shadow:0 0 0 2px var(--ember-40)}
|
||||||
|
/* Cards: the Tuning strip above the list */
|
||||||
|
.tune-strip{padding:var(--s-4) var(--card-pad)}
|
||||||
|
.ts-row{display:flex;align-items:center;gap:var(--s-4);flex-wrap:wrap}
|
||||||
|
.ts-row .goal-line{flex:0 1 auto}
|
||||||
|
.ts-saving{font-size:var(--t-base);color:var(--molten);font-weight:500;margin-left:auto}
|
||||||
|
.ts-saving:empty{display:none}
|
||||||
|
.tune-strip .line-text{margin-top:var(--s-2);font-size:var(--t-base);color:var(--ash)}
|
||||||
|
.tune-strip .switch{padding-bottom:0}
|
||||||
|
/* the Ember layer on a row: the flame mark, the saving, the sparkline, the curve */
|
||||||
|
.gl-tune{align-items:center}
|
||||||
|
.flame{flex:0 0 16px;display:inline-block}
|
||||||
|
.flame .fo{fill:none;stroke:var(--ash);stroke-width:7}
|
||||||
|
.flame .ff{fill:var(--ember)}
|
||||||
|
.flame.running .ff{fill:var(--molten)}
|
||||||
|
.flame.measured .ff{fill:var(--ash)}
|
||||||
|
.flame.idle .ff,.flame.paused .ff,.flame.stopped .ff{fill:none}
|
||||||
|
.gl-tune.tuned .t{color:var(--ink-2)}
|
||||||
|
.gl-tune .save,.gl-details .save{color:var(--molten);white-space:nowrap}
|
||||||
|
.num .k.spark{display:inline-flex;align-items:center;gap:6px}
|
||||||
|
.spark{display:inline-block;vertical-align:middle}
|
||||||
|
.spark path{fill:none;stroke:var(--ash);stroke-width:1.2}
|
||||||
|
.spark circle{fill:var(--ember)}
|
||||||
|
.curve-wrap{display:flex;flex-direction:column;gap:6px;max-width:420px}
|
||||||
|
.curve-svg{width:100%;height:auto;background:var(--obsidian);border:1px solid var(--line);border-radius:10px}
|
||||||
|
.curve-svg .ln{fill:none;stroke:var(--ash);stroke-width:1.2}
|
||||||
|
.curve-svg circle{fill:var(--ember)}
|
||||||
|
.curve-svg circle.chosen{fill:none;stroke:var(--ember);stroke-width:2}
|
||||||
|
.curve-svg circle.marked{fill:none;stroke:var(--ash);stroke-width:1.2}
|
||||||
|
.curve-svg text{font-family:var(--mono);font-size:9px;fill:var(--ash)}
|
||||||
|
.seg.small .seg-b{padding:4px 10px;font-size:var(--t-sm)}
|
||||||
|
.gl-details .ctl .seg{justify-self:start}
|
||||||
|
@media (max-width:1180px){
|
||||||
|
.totals{grid-template-columns:1fr 1fr 1fr 1fr}
|
||||||
|
.tot:first-child .v{font-size:64px}
|
||||||
|
}
|
||||||
|
@media (max-width:860px){
|
||||||
|
.totals{grid-template-columns:1fr 1fr;row-gap:var(--s-4)}
|
||||||
|
.tot:first-child{grid-column:1 / -1}
|
||||||
|
.tot:nth-child(3){padding-left:0}
|
||||||
|
}
|
||||||
|
@media (max-width:720px){
|
||||||
|
.tot:first-child .v{font-size:56px}
|
||||||
|
.toggle-big .ts{display:none}
|
||||||
|
#dag-live{height:170px}
|
||||||
|
.ts-saving{margin-left:0}
|
||||||
|
.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)}}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
<link rel="icon" href="mark.svg" type="image/svg+xml">
|
<link rel="icon" href="mark.svg" type="image/svg+xml">
|
||||||
<link rel="stylesheet" href="app.css">
|
<link rel="stylesheet" href="app.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="phase-welcome" data-phase="welcome" data-page="mine">
|
<body class="phase-welcome" data-phase="welcome" data-page="overview">
|
||||||
|
|
||||||
<!-- the rail: four sections (miner-ui-3: Mine, Earnings, Prove, Settings), Logs and Quit in the foot. Under 720 px it
|
<!-- the rail: four sections (miner-ui-3: Mine, Earnings, Prove, Settings), Logs and Quit in the foot. Under 720 px it
|
||||||
is a bottom tab bar. The setup screens have no rail. -->
|
is a bottom tab bar. The setup screens have no rail. -->
|
||||||
|
|
@ -18,7 +18,8 @@
|
||||||
<span class="word">IGNEUM</span>
|
<span class="word">IGNEUM</span>
|
||||||
</div>
|
</div>
|
||||||
<nav class="rail-nav" id="rail-nav" aria-label="Sections">
|
<nav class="rail-nav" id="rail-nav" aria-label="Sections">
|
||||||
<button class="nav on" data-page="mine" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/></svg><span>Mine</span></button>
|
<button class="nav on" data-page="overview" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/></svg><span>Overview</span></button>
|
||||||
|
<button class="nav" data-page="cards"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="5" width="18" height="11" rx="2"/><path d="M7 20h10M9 16v4M15 16v4M7 10h3M14 10h3"/></svg><span>Cards</span></button>
|
||||||
<button class="nav" data-page="earnings"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M16 15h2"/></svg><span>Earnings</span></button>
|
<button class="nav" data-page="earnings"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M16 15h2"/></svg><span>Earnings</span></button>
|
||||||
<button class="nav" data-page="prove"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 9.4 8 11 4.6-1.6 8-6 8-11V5l-8-3z"/><path d="m9 12 2 2 4-4"/></svg><span>Prove</span></button>
|
<button class="nav" data-page="prove"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 9.4 8 11 4.6-1.6 8-6 8-11V5l-8-3z"/><path d="m9 12 2 2 4-4"/></svg><span>Prove</span></button>
|
||||||
<button class="nav" data-page="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg><span>Settings</span><i class="nav-dot" id="nav-updates-dot" hidden></i></button>
|
<button class="nav" data-page="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg><span>Settings</span><i class="nav-dot" id="nav-updates-dot" hidden></i></button>
|
||||||
|
|
@ -37,7 +38,7 @@
|
||||||
<span class="word">IGNEUM</span><span class="miner">MINER</span>
|
<span class="word">IGNEUM</span><span class="miner">MINER</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-title" id="page-title" hidden>
|
<div class="page-title" id="page-title" hidden>
|
||||||
<h1 id="page-title-text">Mine</h1>
|
<h1 id="page-title-text">Overview</h1>
|
||||||
<span class="page-sub" id="page-sub"></span>
|
<span class="page-sub" id="page-sub"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-right">
|
<div class="top-right">
|
||||||
|
|
@ -48,8 +49,10 @@
|
||||||
<!-- the status strip: one notice at a time (app.js, Notices) -->
|
<!-- the status strip: one notice at a time (app.js, Notices) -->
|
||||||
<div class="notices" id="notices" hidden>
|
<div class="notices" id="notices" hidden>
|
||||||
<div class="notice" id="notice" role="status" aria-live="polite">
|
<div class="notice" id="notice" role="status" aria-live="polite">
|
||||||
|
<i class="notice-dot" aria-hidden="true"></i>
|
||||||
<span class="notice-text" id="notice-text"></span>
|
<span class="notice-text" id="notice-text"></span>
|
||||||
<span class="notice-actions" id="notice-actions"></span>
|
<span class="notice-actions" id="notice-actions"></span>
|
||||||
|
<button class="notice-more" id="notice-more" data-act="more" title="The technical line" aria-label="Show the technical line" aria-expanded="false" hidden><span class="chev"></span></button>
|
||||||
<button class="notice-close" id="notice-close" data-act="close" title="Close" aria-label="Close">×</button>
|
<button class="notice-close" id="notice-close" data-act="close" title="Close" aria-label="Close">×</button>
|
||||||
<span class="notice-detail mono" id="notice-detail" hidden></span>
|
<span class="notice-detail mono" id="notice-detail" hidden></span>
|
||||||
<span class="prog" id="notice-prog" hidden><i></i></span>
|
<span class="prog" id="notice-prog" hidden><i></i></span>
|
||||||
|
|
@ -115,7 +118,7 @@
|
||||||
</div>
|
</div>
|
||||||
<p class="note" id="cards-note" hidden></p>
|
<p class="note" id="cards-note" hidden></p>
|
||||||
<p class="note" id="cards-power" hidden>NVIDIA cards start at 80% of their power limit, which keeps them stable. Windows asks for administrator rights once for that. The card row has a slider.</p>
|
<p class="note" id="cards-power" hidden>NVIDIA cards start at 80% of their power limit, which keeps them stable. Windows asks for administrator rights once for that. The card row has a slider.</p>
|
||||||
<p class="note" id="cards-help" hidden>An integrated GPU is off by default: it is slow and shares the machine's memory.</p>
|
<p class="note" id="cards-help" hidden>A built-in GPU starts off. It is slow and shares the machine's memory.</p>
|
||||||
<div class="cta">
|
<div class="cta">
|
||||||
<button class="btn primary" id="btn-cards-next" disabled>Continue</button>
|
<button class="btn primary" id="btn-cards-next" disabled>Continue</button>
|
||||||
<button class="btn ghost" id="btn-cards-retry" hidden>Detect again</button>
|
<button class="btn ghost" id="btn-cards-retry" hidden>Detect again</button>
|
||||||
|
|
@ -143,7 +146,7 @@
|
||||||
<span class="radio"></span>
|
<span class="radio"></span>
|
||||||
<span class="body">
|
<span class="body">
|
||||||
<span class="t">Use my own address</span>
|
<span class="t">Use my own address</span>
|
||||||
<span class="s">Paste an Ethereum-style address you control: 0x and 40 characters.</span>
|
<span class="s">Paste an address you control. It starts with 0x and has 40 characters after it.</span>
|
||||||
<input type="text" class="addr-input mono" id="addr-input" placeholder="0x" spellcheck="false" autocomplete="off">
|
<input type="text" class="addr-input mono" id="addr-input" placeholder="0x" spellcheck="false" autocomplete="off">
|
||||||
<span class="err" id="addr-err" hidden>That is not an address. 0x followed by 40 hex characters.</span>
|
<span class="err" id="addr-err" hidden>That is not an address. 0x followed by 40 hex characters.</span>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -160,8 +163,8 @@
|
||||||
<!-- 4. the dashboard: four pages behind the rail -->
|
<!-- 4. the dashboard: four pages behind the rail -->
|
||||||
<section class="screen" id="screen-dashboard">
|
<section class="screen" id="screen-dashboard">
|
||||||
|
|
||||||
<!-- Mine -->
|
<!-- Overview: the fleet hero, the chain scene, the node line, activity -->
|
||||||
<section class="page" id="page-mine" data-page="mine">
|
<section class="page" id="page-overview" data-page="overview">
|
||||||
<div class="clock-card" id="m-clock" hidden>
|
<div class="clock-card" id="m-clock" hidden>
|
||||||
<p class="clock-msg" id="m-clock-msg"></p>
|
<p class="clock-msg" id="m-clock-msg"></p>
|
||||||
<div class="row"><button class="btn small primary" id="m-clock-sync">Sync clock</button><span class="note mono small" id="m-clock-result"></span></div>
|
<div class="row"><button class="btn small primary" id="m-clock-sync">Sync clock</button><span class="note mono small" id="m-clock-result"></span></div>
|
||||||
|
|
@ -182,24 +185,73 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<!-- 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-head"><h3>Your cards</h3><div class="head-right"><span class="eyebrow" id="d-cards-eyebrow">detecting</span><button class="btn small" id="btn-tune-all" hidden>Tune all</button></div></div>
|
<div class="card wait-card" id="first-wait" hidden>
|
||||||
<div class="gpu-list" id="d-cards"><div class="empty">Waiting for the engine.</div></div>
|
<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>
|
||||||
<p class="note" id="d-cards-note" hidden></p>
|
<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>
|
||||||
|
|
||||||
<div class="card" id="tune-card">
|
<div class="card dag-card" id="dag-card">
|
||||||
<div class="card-head"><h3>Tuning</h3><span class="eyebrow" id="tune-eyebrow">Ember Tune</span></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>
|
||||||
<div class="goal-row">
|
<!-- 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="seg" id="goal-seg" role="radiogroup" aria-label="Tuning goal">
|
<div class="dag-wrap">
|
||||||
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false">Efficiency</button>
|
<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>
|
||||||
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false">Balanced</button>
|
<div class="dag-tip mono" id="dag-tip" hidden></div>
|
||||||
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false">Maximum</button>
|
<span class="dag-chip mono" id="dag-chip" hidden>scroll to zoom · Esc to release</span>
|
||||||
</div>
|
<canvas id="dag" aria-hidden="true" hidden></canvas>
|
||||||
<span class="goal-line" id="goal-line"></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>
|
||||||
<p class="line-text" id="tune-schedule"></p>
|
|
||||||
<label class="switch" id="m-power-row" hidden><input type="checkbox" id="m-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once.</span></span></label>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card node-card" id="node-card">
|
<div class="card node-card" id="node-card">
|
||||||
|
|
@ -210,25 +262,26 @@
|
||||||
<div class="details" id="node-details" hidden>
|
<div class="details" id="node-details" hidden>
|
||||||
<div class="kv">
|
<div class="kv">
|
||||||
<div><span class="k">state</span><span class="v mono" id="n-state-v"></span><span class="m" id="n-state-m"></span></div>
|
<div><span class="k">state</span><span class="v mono" id="n-state-v"></span><span class="m" id="n-state-m"></span></div>
|
||||||
|
<div><span class="k">whose node</span><span class="v" id="n-source"></span><span class="m" id="n-source-m"></span></div>
|
||||||
<div><span class="k">height</span><span class="v mono" id="n-blocks">0</span><span class="m" id="n-blocks-sub">blocks this node holds</span></div>
|
<div><span class="k">height</span><span class="v mono" id="n-blocks">0</span><span class="m" id="n-blocks-sub">blocks this node holds</span></div>
|
||||||
<div><span class="k">peers</span><span class="v mono" id="n-peers">0</span><span class="m" id="n-peers-sub">other nodes it talks to</span></div>
|
<div><span class="k">peers</span><span class="v mono" id="n-peers">0</span><span class="m" id="n-peers-sub">other nodes it talks to</span></div>
|
||||||
<div><span class="k">version</span><span class="v mono" id="n-version">--</span><span class="m" id="d-node-net">devnet v4</span></div>
|
<div><span class="k">version</span><span class="v mono" id="n-version">--</span><span class="m" id="d-node-net">devnet v4</span></div>
|
||||||
<div><span class="k">headers</span><span class="v mono" id="n-headers">0</span><span class="m">headers arrive before blocks</span></div>
|
<div><span class="k">headers</span><span class="v mono" id="n-headers">0</span><span class="m">headers arrive before blocks</span></div>
|
||||||
<div><span class="k">DAA score</span><span class="v mono" id="n-daa">0</span><span class="m">blocks the whole network has made</span></div>
|
<div><span class="k">network blocks</span><span class="v mono" id="n-daa">0</span><span class="m">blocks the whole network has made (the DAA score)</span></div>
|
||||||
<div><span class="k">difficulty</span><span class="v mono" id="n-diff">0</span><span class="m">how hard the next block is to find</span></div>
|
<div><span class="k">difficulty</span><span class="v mono" id="n-diff">0</span><span class="m">how hard the next block is to find</span></div>
|
||||||
<div><span class="k">tips</span><span class="v mono" id="n-tips">0</span><span class="m">open ends of the block DAG right now</span></div>
|
<div><span class="k">tips</span><span class="v mono" id="n-tips">0</span><span class="m">open ends of the chain right now</span></div>
|
||||||
<div><span class="k">blue score</span><span class="v mono" id="n-blue">0</span><span class="m">blocks on the agreed main chain</span></div>
|
<div><span class="k">blue score</span><span class="v mono" id="n-blue">0</span><span class="m">blocks on the agreed main chain (the blue score)</span></div>
|
||||||
<div><span class="k">next program</span><span class="v mono" id="d-eta">--:--</span><span class="m" id="d-eta-sub">the hourly mining program</span></div>
|
<div><span class="k">next program</span><span class="v mono" id="d-eta">--:--</span><span class="m" id="d-eta-sub">the hourly mining program</span></div>
|
||||||
<div><span class="k">last lock</span><span class="v mono" id="f-lock">none yet</span><span class="m" id="f-note">a point the miners agreed can never be undone</span></div>
|
<div><span class="k">last checkpoint</span><span class="v mono" id="f-lock">none yet</span><span class="m" id="f-note">a point the miners agreed can never be undone</span></div>
|
||||||
<div><span class="k">votes sent</span><span class="v mono" id="f-votes">0</span><span class="m">this machine signs a checkpoint every 30 s</span></div>
|
<div><span class="k">votes sent</span><span class="v mono" id="f-votes">0</span><span class="m">this machine signs a checkpoint every 30 s (a point the miners agree can never be undone)</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="k">rules digest</div>
|
<div class="k">rules fingerprint</div>
|
||||||
<div class="box mono"><span id="n-digest">not printed yet</span><button class="btn tiny" data-copy="n-digest">Copy</button></div>
|
<div class="box mono"><span id="n-digest">not printed yet</span><button class="btn tiny" data-copy="n-digest">Copy</button></div>
|
||||||
<p class="help">Every node on the network shows the same fingerprint of the rules. A peer with another one is refused.</p>
|
<p class="help">Every node on the network shows the same fingerprint of the rules. A peer with another one is refused.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="k">next rule switch</div>
|
<div class="k">next rule change</div>
|
||||||
<div class="line-text" id="n-switch">none planned</div>
|
<div class="line-text" id="n-switch">none planned</div>
|
||||||
<p class="help" id="n-switch-help"></p>
|
<p class="help" id="n-switch-help"></p>
|
||||||
<div class="switch-list mono" id="n-switches"></div>
|
<div class="switch-list mono" id="n-switches"></div>
|
||||||
|
|
@ -238,24 +291,67 @@
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-head"><h3>Activity</h3><div class="head-right"><span class="stats mono" id="d-stats"></span><button class="btn small ghost" id="btn-open-log">Open the log</button></div></div>
|
<div class="card-head"><h3>Activity</h3><div class="head-right"><span class="stats mono" id="d-stats"></span><button class="btn small ghost" id="btn-open-log">Open the log</button></div></div>
|
||||||
<canvas id="dag" aria-hidden="true"></canvas>
|
|
||||||
<div class="feed" id="d-events"><div class="empty">No events yet.</div></div>
|
<div class="feed" id="d-events"><div class="empty">No events yet.</div></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Cards: every card with Ember as its second layer -->
|
||||||
|
<section class="page" id="page-cards" data-page="cards" hidden>
|
||||||
|
<div class="card tune-strip" id="tune-card">
|
||||||
|
<div class="card-head"><h3>Ember Tune</h3><span class="eyebrow">tunes every card for hashes per watt</span></div>
|
||||||
|
<div class="ts-row">
|
||||||
|
<div class="seg" id="goal-seg" role="radiogroup" aria-label="Tuning goal">
|
||||||
|
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false" title="most hashes per watt, a little rate traded">Efficiency</button>
|
||||||
|
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false" title="a little rate for most of the saving">Balanced</button>
|
||||||
|
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false" title="most hashes, watts second">Maximum</button>
|
||||||
|
</div>
|
||||||
|
<span class="goal-line" id="goal-line"></span>
|
||||||
|
<span class="ts-saving" id="fleet-saving"></span>
|
||||||
|
<button class="btn small" id="btn-tune-all" hidden>Tune all</button>
|
||||||
|
</div>
|
||||||
|
<p class="line-text" id="tune-schedule"></p>
|
||||||
|
<label class="switch" id="m-power-row" hidden><input type="checkbox" id="m-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once.</span></span></label>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head"><h3>Your cards</h3><div class="head-right"><span class="eyebrow" id="d-cards-eyebrow">detecting</span></div></div>
|
||||||
|
<div class="gpu-list" id="d-cards"><div class="empty">Waiting for the engine.</div></div>
|
||||||
|
<p class="note" id="d-cards-note" hidden></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Earnings -->
|
<!-- Earnings -->
|
||||||
<section class="page" id="page-earnings" data-page="earnings" hidden>
|
<section class="page" id="page-earnings" data-page="earnings" hidden>
|
||||||
<div class="card">
|
<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">
|
||||||
<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" 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-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-window">0 blocks in 30 days</span><span class="s" id="e-window-sub"></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 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"><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 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>
|
</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 miner software's author</span></label>
|
<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>
|
<p class="help" id="r-devfee-line"></p>
|
||||||
</div>
|
</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">
|
||||||
<div class="card-head"><h3>Rewards address</h3><div class="eyebrow" id="r-source"></div></div>
|
<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>
|
<div class="addr-big mono"><span id="r-address">not set</span><button class="btn small" data-copy="r-address">Copy</button></div>
|
||||||
|
|
@ -293,23 +389,29 @@
|
||||||
|
|
||||||
<!-- Prove -->
|
<!-- Prove -->
|
||||||
<section class="page" id="page-prove" data-page="prove" hidden>
|
<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="card">
|
||||||
<div class="lead-row">
|
<div class="lead-row">
|
||||||
<div class="lead-text">
|
<div class="lead-text">
|
||||||
<h3>Prove on this machine</h3>
|
<h3>Prove on this machine</h3>
|
||||||
<p class="help">Every block is turned into a short proof. The chain hands pieces to your cards; each piece proven pays IGN.</p>
|
<p class="help">Every block is turned into a short proof, in pieces called shards. Your cards prove shards and earn IGN for each one.</p>
|
||||||
</div>
|
</div>
|
||||||
<label class="switch lg" title="Prove on this machine"><input type="checkbox" id="s-prove" aria-label="Prove on this machine"><span class="track"></span></label>
|
<label class="switch lg" title="Prove on this machine"><input type="checkbox" id="s-prove" aria-label="Prove on this machine"><span class="track"></span></label>
|
||||||
</div>
|
</div>
|
||||||
<ul class="tier-list" id="pv-tiers"></ul>
|
<ul class="tier-list" id="pv-tiers"></ul>
|
||||||
<p class="line-text" id="pv-line"></p>
|
<p class="line-text" id="pv-line"></p>
|
||||||
<p class="note" id="pv-note"></p>
|
<p class="note" id="pv-note"></p>
|
||||||
<div class="row" id="pv-setup-row" hidden><button class="btn small primary" id="pv-setup">Set up</button><span class="note">About 20 minutes, once.</span></div>
|
<div class="row" id="pv-setup-row" hidden><button class="btn small primary" id="pv-setup">Set up</button><span class="note">Takes about 20 minutes, once.</span></div>
|
||||||
<button class="disclose" id="pv-toggle" aria-expanded="false" aria-controls="pv-details"><span>Details</span><span class="chev" aria-hidden="true"></span></button>
|
<button class="disclose" id="pv-toggle" aria-expanded="false" aria-controls="pv-details"><span>Details</span><span class="chev" aria-hidden="true"></span></button>
|
||||||
<div class="details" id="pv-details" hidden>
|
<div class="details" id="pv-details" hidden>
|
||||||
<div class="kv">
|
<div class="kv">
|
||||||
<div><span class="k">verifier</span><span class="v" id="pv-verifier">not read yet</span><span class="m" id="pv-verifier-help">the node checks a proof before it counts</span></div>
|
<div><span class="k">verifier</span><span class="v" id="pv-verifier">not read yet</span><span class="m" id="pv-verifier-help">the node checks a proof before it counts</span></div>
|
||||||
<div><span class="k">segments</span><span class="v" id="pv-seg">none yet</span><span class="m" id="pv-seg-note">whole segments this machine proved</span></div>
|
<div><span class="k">segments</span><span class="v" id="pv-seg">none yet</span><span class="m" id="pv-seg-note">whole segments (runs of 8 blocks) this machine proved</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="k">shard program id</div>
|
<div class="k">shard program id</div>
|
||||||
|
|
@ -318,7 +420,7 @@
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="k">aggregator id</div>
|
<div class="k">aggregator id</div>
|
||||||
<div class="box mono"><span id="pv-aggregator">not read yet</span><button class="btn tiny" data-copy="pv-aggregator">Copy</button></div>
|
<div class="box mono"><span id="pv-aggregator">not read yet</span><button class="btn tiny" data-copy="pv-aggregator">Copy</button></div>
|
||||||
<p class="help">Every proof names the program that made it. Other nodes accept a proof only from these two ids.</p>
|
<p class="help">Every proof names the program that made it: the shard program for one shard, the aggregator for a whole segment. Other nodes accept a proof only from these two ids.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -330,19 +432,19 @@
|
||||||
<div class="card-head"><h3>Tuning</h3><span class="eyebrow" id="s-tune-eyebrow">Ember Tune</span></div>
|
<div class="card-head"><h3>Tuning</h3><span class="eyebrow" id="s-tune-eyebrow">Ember Tune</span></div>
|
||||||
<div class="goal-row">
|
<div class="goal-row">
|
||||||
<div class="seg" id="goal-seg-2" role="radiogroup" aria-label="Tuning goal">
|
<div class="seg" id="goal-seg-2" role="radiogroup" aria-label="Tuning goal">
|
||||||
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false">Efficiency</button>
|
<button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false" title="most hashes per watt, a little rate traded">Efficiency</button>
|
||||||
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false">Balanced</button>
|
<button class="seg-b" data-goal="balanced" role="radio" aria-checked="false" title="a little rate for most of the saving">Balanced</button>
|
||||||
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false">Maximum</button>
|
<button class="seg-b" data-goal="rate" role="radio" aria-checked="false" title="most hashes, watts second">Maximum</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="goal-line" id="goal-line-2"></span>
|
<span class="goal-line" id="goal-line-2"></span>
|
||||||
</div>
|
</div>
|
||||||
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span class="sw-text"><b>Ember Tune</b> <span class="dim">Tunes every card for hashes per watt: once after install, then every 7 days, and after a driver or program change.</span></span></label>
|
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span class="sw-text"><b>Ember Tune</b> <span class="dim">Tunes every card for the most hashes per watt. Once after install, then every 7 days, and after a driver or program change.</span></span></label>
|
||||||
<label class="switch"><input type="checkbox" id="s-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once. Off, NVIDIA cards are measured only.</span><span class="dim" id="s-power-note"></span></span></label>
|
<label class="switch"><input type="checkbox" id="s-power-control"><span class="track"></span><span class="sw-text"><b>Power control</b> <span class="dim">Lets the app set NVIDIA power and clock limits. Windows asks for administrator rights once. Off, NVIDIA cards are only measured.</span><span class="dim" id="s-power-note"></span></span></label>
|
||||||
<label class="switch" id="s-climb-row" hidden><input type="checkbox" id="s-climb"><span class="track"></span><span class="sw-text"><b>Hill climb</b> <span class="dim">Searches around the best point instead of walking the ladders.</span></span></label>
|
<label class="switch" id="s-climb-row" hidden><input type="checkbox" id="s-climb"><span class="track"></span><span class="sw-text"><b>Fine tuning</b> <span class="dim">Searches around the best point instead of stepping down a ladder.</span></span></label>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="k">electricity price</div>
|
<div class="k">electricity price</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<input type="number" class="addr-input mono short" id="s-price" min="0" max="200" step="0.1" placeholder="28" aria-label="Electricity price in pence per kWh"><span class="note">pence per kWh. Every £ figure uses it.</span>
|
<input type="number" class="addr-input mono short" id="s-price" min="0" max="200" step="0.1" placeholder="28" aria-label="Electricity price in pence per kWh"><span class="note">pence per kWh. Every £ figure uses this.</span>
|
||||||
<button class="btn small" id="s-price-save">Save</button>
|
<button class="btn small" id="s-price-save">Save</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -352,7 +454,8 @@
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-head"><h3>This machine</h3></div>
|
<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-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-jobs-allow"><span class="track"></span><span class="sw-text"><b>Allow remote jobs from Igneum</b> <span class="dim">Signed jobs (a benchmark, a script, logs to collect) run here once and report back.</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="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>
|
<div class="details indent" id="s-jobs-history" hidden></div>
|
||||||
<p class="note mono small indent" id="s-jobs-key" hidden></p>
|
<p class="note mono small indent" id="s-jobs-key" hidden></p>
|
||||||
|
|
@ -362,7 +465,7 @@
|
||||||
<input type="text" class="addr-input" id="s-name" placeholder="a name for this machine" maxlength="40" spellcheck="false" aria-label="Machine name">
|
<input type="text" class="addr-input" id="s-name" placeholder="a name for this machine" maxlength="40" spellcheck="false" aria-label="Machine name">
|
||||||
<button class="btn small" id="s-name-save">Rename</button>
|
<button class="btn small" id="s-name-save">Rename</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="help">A label for you only.</p>
|
<p class="help">A name for you only.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<div class="k">appearance</div>
|
<div class="k">appearance</div>
|
||||||
|
|
@ -401,8 +504,8 @@
|
||||||
|
|
||||||
<details class="card adv" id="s-advanced">
|
<details class="card adv" id="s-advanced">
|
||||||
<summary><h3>Advanced</h3><span class="eyebrow">devnet</span></summary>
|
<summary><h3>Advanced</h3><span class="eyebrow">devnet</span></summary>
|
||||||
<label class="switch"><input type="checkbox" id="s-vote"><span class="track"></span><span class="sw-text"><b>Vote on finality checkpoints</b> <span class="dim">Signs a checkpoint every 30 s. Votes lock the chain; leave it on.</span></span></label>
|
<label class="switch"><input type="checkbox" id="s-vote"><span class="track"></span><span class="sw-text"><b>Vote on checkpoints</b> <span class="dim">Signs a checkpoint every 30 s. Votes are what lock the chain. Leave it on.</span></span></label>
|
||||||
<label class="switch"><input type="checkbox" id="s-trust"><span class="track"></span><span class="sw-text"><b>Trust proof records without verifying them</b> <span class="dim">Devnet only. Without a verifier the node includes records it never checked. Changing this restarts the node.</span></span></label>
|
<label class="switch"><input type="checkbox" id="s-trust"><span class="track"></span><span class="sw-text"><b>Trust proofs without checking them</b> <span class="dim">Devnet only. The node includes proofs it never checked. Changing this restarts the node.</span></span></label>
|
||||||
<div class="ask inline" id="ask-trust" hidden>
|
<div class="ask inline" id="ask-trust" hidden>
|
||||||
<span class="ask-text" id="ask-trust-text"></span>
|
<span class="ask-text" id="ask-trust-text"></span>
|
||||||
<button class="btn small primary" id="ask-trust-yes">Confirm</button>
|
<button class="btn small primary" id="ask-trust-yes">Confirm</button>
|
||||||
|
|
@ -494,6 +597,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast" hidden></div>
|
<div class="toast" id="toast" hidden></div>
|
||||||
|
<script src="live-dag.js"></script>
|
||||||
|
<script src="proof-core.js"></script>
|
||||||
<script src="app.js"></script>
|
<script src="app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
349
app/igneum-app/ui/live-dag.js
Normal file
|
|
@ -0,0 +1,349 @@
|
||||||
|
/* 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 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 mount(canvas, opts) {
|
||||||
|
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;}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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 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;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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});
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
// 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(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 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 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)';
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
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,version:'2.0.1'};
|
||||||
|
})(window);
|
||||||
|
|
@ -96,7 +96,7 @@ test('update wording: available, downloading with percent, installing, failed wi
|
||||||
assert.equal(updateNotice(upd({ status: 'deferred' }), s()).text, 'Igneum Miner 0.3.6 is waiting for permission. It installs the next time someone is at this PC. Mining continues.');
|
assert.equal(updateNotice(upd({ status: 'deferred' }), s()).text, 'Igneum Miner 0.3.6 is waiting for permission. It installs the next time someone is at this PC. Mining continues.');
|
||||||
// updated: gone within 60 s of the new version starting
|
// updated: gone within 60 s of the new version starting
|
||||||
const cur = upd({ status: 'current', available: false, ready: false, downloaded: false, updated_from: '0.3.4' });
|
const cur = upd({ status: 'current', available: false, ready: false, downloaded: false, updated_from: '0.3.4' });
|
||||||
assert.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 59 })).text, 'Updated to Igneum Miner 0.3.6 from 0.3.4.');
|
assert.match(updateNotice(cur, s({ version: '0.3.6', uptime_s: 59, now: 1_800_000_000 })).text, /^Updated to 0\.3\.6 at \d\d:\d\d\.$/);
|
||||||
assert.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 60 })), null);
|
assert.equal(updateNotice(cur, s({ version: '0.3.6', uptime_s: 60 })), null);
|
||||||
assert.equal(updateNotice(upd({ status: 'current', available: false }), s()), null);
|
assert.equal(updateNotice(upd({ status: 'current', available: false }), s()), null);
|
||||||
// a build with no manifest reports an error the user cannot act on: no notice (the Settings note still says it)
|
// a build with no manifest reports an error the user cannot act on: no notice (the Settings note still says it)
|
||||||
|
|
@ -106,22 +106,26 @@ test('update wording: available, downloading with percent, installing, failed wi
|
||||||
|
|
||||||
test('job wording: running with minutes and stage, done goes after 5 minutes, failed stays with the first error line', () => {
|
test('job wording: running with minutes and stage, done goes after 5 minutes, failed stays with the first error line', () => {
|
||||||
const r = jobNotice(run(), NOW);
|
const r = jobNotice(run(), NOW);
|
||||||
assert.equal(r.text, 'Job: shard benchmark running, 6 min. Proving shard 3 of 8.');
|
assert.equal(r.text, 'A job from the team is running: shard benchmark, 6 min. Proving shard 3 of 8.');
|
||||||
assert.equal(r.detail, 'RESULT shard=2 prove_s=39.8');
|
assert.equal(r.detail, 'shard benchmark · RESULT shard=2 prove_s=39.8', 'the technical line goes behind the chevron');
|
||||||
assert.equal(jobNotice(run({ stage: '' }), NOW).text, 'Job: shard benchmark running, 6 min.');
|
assert.equal(jobNotice(run({ stage: '' }), NOW).text, 'A job from the team is running: shard benchmark, 6 min.');
|
||||||
const d = jobNotice(fin('done'), NOW);
|
const d = jobNotice(fin('done'), NOW);
|
||||||
assert.equal(d.text, 'Job: build done after 32 min. Report uploaded.');
|
assert.equal(d.text, 'A job from the team ran: build, 32 min.');
|
||||||
assert.equal(d.key, 'job:done:job-6');
|
assert.equal(d.key, 'job:done:job-6');
|
||||||
assert.ok(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S));
|
assert.ok(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S));
|
||||||
assert.equal(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S + 1), null);
|
assert.equal(jobNotice(fin('done'), NOW - 60 + N.JOB_DONE_S + 1), null);
|
||||||
const f = jobNotice(fin('failed'), NOW);
|
const f = jobNotice(fin('failed'), NOW);
|
||||||
assert.equal(f.text, 'Job: build failed after 32 min, exit 1. Report not uploaded.');
|
assert.equal(f.text, 'A job from the team failed: build, after 32 min. The report did not upload.');
|
||||||
assert.equal(f.detail, 'BUILD FAILED: error[E0425]: cannot find value `foo`');
|
assert.match(f.detail, /BUILD FAILED: error\[E0425\]: cannot find value `foo` · exit 1$/);
|
||||||
assert.equal(f.tone, 'bad');
|
assert.equal(f.tone, 'bad');
|
||||||
assert.ok(jobNotice(fin('failed'), NOW + 86400 * 7), 'a failed job stays until closed');
|
assert.ok(jobNotice(fin('failed'), NOW + 86400 * 7), 'a failed job stays until closed');
|
||||||
// no error line among the results: the summary is the cause
|
// no error line among the results: the summary is the cause
|
||||||
assert.equal(jobNotice(fin('failed', { last_results: ['STAGE build'] }), NOW).detail, 'build exited with code 1');
|
assert.match(jobNotice(fin('failed', { last_results: ['STAGE build'] }), NOW).detail, /^build: build exited with code 1 · exit 1$/);
|
||||||
assert.equal(jobNotice(fin('timeout'), NOW).text, 'Job: build hit its time cap after 32 min, exit 1. Report not uploaded.');
|
assert.equal(jobNotice(fin('timeout'), NOW).text, 'A job from the team ran out of time: build, after 32 min. The report did not upload.');
|
||||||
|
assert.equal(N.jobWord('', 'update-now-0313-switch-d937c69d'), 'update check');
|
||||||
|
assert.equal(N.jobWord('restart', 'restart-miners-0312'), 'restart');
|
||||||
|
assert.equal(N.jobWord('collect', ''), 'log collection');
|
||||||
|
assert.equal(N.jobWord('', 'ember-tune-pc1-5'), 'tuning check');
|
||||||
assert.equal(jobNotice({ active: false, last: {} }, NOW), null);
|
assert.equal(jobNotice({ active: false, last: {} }, NOW), null);
|
||||||
assert.equal(jobNotice(null, NOW), null);
|
assert.equal(jobNotice(null, NOW), null);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
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);
|
||||||
|
|
@ -14,10 +14,11 @@ const V = mod.exports.View;
|
||||||
|
|
||||||
const card = (over) => ({ key: 'nvidia:0:RTX 5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', kind: 'discrete', worker: 'CUDA', vram_mb: 32768, enabled: true, state: 'mining', hash_now: 124.3, hash_avg: 120, accepted: 3, rejected: 0, identities: 8, ids: [], prepared: true, restart_in_s: 0, message: '', reason: '', power_w: 410.2, power_limit_w: 460, power_default_w: 575, power_pct: 80, power_applied: true, temp_gpu: 61, temp_mem: 72, telemetry_at: 1, ...over });
|
const card = (over) => ({ key: 'nvidia:0:RTX 5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', kind: 'discrete', worker: 'CUDA', vram_mb: 32768, enabled: true, state: 'mining', hash_now: 124.3, hash_avg: 120, accepted: 3, rejected: 0, identities: 8, ids: [], prepared: true, restart_in_s: 0, message: '', reason: '', power_w: 410.2, power_limit_w: 460, power_default_w: 575, power_pct: 80, power_applied: true, temp_gpu: 61, temp_mem: 72, telemetry_at: 1, ...over });
|
||||||
|
|
||||||
test('the four sections and their order (miner-ui-3)', () => {
|
test('the five sections and their order (miner-ui-4)', () => {
|
||||||
assert.deepEqual(V.PAGES.map((p) => p.id), ['mine', 'earnings', 'prove', 'settings']);
|
assert.deepEqual(V.PAGES.map((p) => p.id), ['overview', 'cards', 'earnings', 'prove', 'settings']);
|
||||||
|
assert.equal(V.page('cards').title, 'Cards');
|
||||||
assert.equal(V.page('earnings').title, 'Earnings');
|
assert.equal(V.page('earnings').title, 'Earnings');
|
||||||
assert.equal(V.page('nonsense').id, 'mine');
|
assert.equal(V.page('nonsense').id, 'overview');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a GPU row: name, kind, the numbers where known, the switch', () => {
|
test('a GPU row: name, kind, the numbers where known, the switch', () => {
|
||||||
|
|
@ -72,8 +73,8 @@ test('the big button: start when paused, stop when mining, disabled with no card
|
||||||
const none = V.toggle(m({ cards: [card({ enabled: false })] }), { synced: true }, {});
|
const none = V.toggle(m({ cards: [card({ enabled: false })] }), { synced: true }, {});
|
||||||
assert.equal(none.disabled, true);
|
assert.equal(none.disabled, true);
|
||||||
assert.equal(none.act, '');
|
assert.equal(none.act, '');
|
||||||
assert.equal(none.sub, 'switch a GPU on below first');
|
assert.equal(none.sub, 'switch a card on in Cards first');
|
||||||
assert.equal(V.toggle(m({ cards: [] }), { synced: true }, {}).sub, 'no GPU this app can drive');
|
assert.equal(V.toggle(m({ cards: [] }), { synced: true }, {}).sub, 'no card this app can use');
|
||||||
assert.equal(V.toggle(m({ state: 'waiting' }), { synced: false }, {}).sub, 'waiting for the node to sync');
|
assert.equal(V.toggle(m({ state: 'waiting' }), { synced: false }, {}).sub, 'waiting for the node to sync');
|
||||||
assert.equal(V.toggle(m(), { synced: true }, { quitting: true }).disabled, true);
|
assert.equal(V.toggle(m(), { synced: true }, { quitting: true }).disabled, true);
|
||||||
});
|
});
|
||||||
|
|
@ -106,11 +107,11 @@ test('the next consensus switch is the first height above the DAA score, in plai
|
||||||
const next = V.nextSwitch(sw, 140000);
|
const next = V.nextSwitch(sw, 140000);
|
||||||
assert.equal(next.name, 'Fees v1');
|
assert.equal(next.name, 'Fees v1');
|
||||||
assert.equal(next.away, 70000);
|
assert.equal(next.away, 70000);
|
||||||
assert.equal(V.switchLine(next, 140000), 'Fees v1 at DAA 210,000: 70,000 blocks away, about 19 h 27 min at one block a second.');
|
assert.equal(V.switchLine(next, 140000), 'Fees v1 at block 210,000: 70,000 blocks away, about 19 h 27 min at one block a second.');
|
||||||
assert.equal(V.nextSwitch(sw, 20000).name, 'Difficulty v2');
|
assert.equal(V.nextSwitch(sw, 20000).name, 'Difficulty v2');
|
||||||
assert.equal(V.nextSwitch(sw, 300000), null);
|
assert.equal(V.nextSwitch(sw, 300000), null);
|
||||||
assert.match(V.switchLine(null, 300000), /Every planned switch is behind this node/);
|
assert.match(V.switchLine(null, 300000), /Every planned rule change is behind this node/);
|
||||||
assert.match(V.switchLine(null, 0), /^A switch is a planned rule change/);
|
assert.match(V.switchLine(null, 0), /^A rule change is planned ahead/);
|
||||||
assert.equal(V.nextSwitch([], 5), null);
|
assert.equal(V.nextSwitch([], 5), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -139,9 +140,9 @@ test('the dev-fee lines name the share and the switch that turns it off', () =>
|
||||||
|
|
||||||
test('the remote-jobs line and the helpers', () => {
|
test('the remote-jobs line and the helpers', () => {
|
||||||
const title = (j) => j.title || j.kind;
|
const title = (j) => j.title || j.kind;
|
||||||
assert.equal(V.jobsNote({ allowed: false }, 1000, title), 'Off: nothing runs here until the switch above allows remote jobs.');
|
assert.equal(V.jobsNote({ allowed: false }, 1000, title), 'Off. Nothing runs here until the switch above allows jobs from the team.');
|
||||||
assert.equal(V.jobsNote({ allowed: true, url_set: false }, 1000, title), 'No jobs address in this build.');
|
assert.equal(V.jobsNote({ allowed: true, url_set: false }, 1000, title), 'This build has no jobs address.');
|
||||||
assert.equal(V.jobsNote({ allowed: true, url_set: true, active: true, id: 'job-3', kind: 'build', title: 'build' }, 1000, title), 'Running build (job-3).');
|
assert.equal(V.jobsNote({ allowed: true, url_set: true, active: true, id: 'job-3', kind: 'build', title: 'build' }, 1000, title), 'Running: build.');
|
||||||
assert.equal(V.jobsNote({ allowed: true, url_set: true, checked_at: 940, queued: 2 }, 1000, title), 'Nothing running; checked 1 min ago; 2 queued.');
|
assert.equal(V.jobsNote({ allowed: true, url_set: true, checked_at: 940, queued: 2 }, 1000, title), 'Nothing running; checked 1 min ago; 2 queued.');
|
||||||
assert.equal(V.shortHex('0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a'), '0x2b1a81…79ef7a');
|
assert.equal(V.shortHex('0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a'), '0x2b1a81…79ef7a');
|
||||||
assert.equal(V.shortHex('0xabc'), '0xabc');
|
assert.equal(V.shortHex('0xabc'), '0xabc');
|
||||||
|
|
@ -159,7 +160,7 @@ test('hot-plug (src/hotplug.rs): a removed card and a faulty card are shown as s
|
||||||
assert.equal(r.word, 'removed');
|
assert.equal(r.word, 'removed');
|
||||||
assert.equal(r.tone, 'off');
|
assert.equal(r.tone, 'off');
|
||||||
assert.equal(r.hash, '');
|
assert.equal(r.hash, '');
|
||||||
assert.match(r.sub, /^unplugged; its worker stopped/);
|
assert.match(r.sub, /^unplugged\. Its row goes/);
|
||||||
const bad = card({ key: 'amd:gfx1201#2', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd', enabled: false, state: 'unusable', problem: 'Code 43', message: 'not usable (Code 43)', reason: 'reboot with the card attached; if it persists, reinstall the driver with the card attached', added_at: 900, removed_at: 0, device: '1', platform: 'AMD Accelerated Parallel Processing', bus: '0000:03:00.0' });
|
const bad = card({ key: 'amd:gfx1201#2', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd', enabled: false, state: 'unusable', problem: 'Code 43', message: 'not usable (Code 43)', reason: 'reboot with the card attached; if it persists, reinstall the driver with the card attached', added_at: 900, removed_at: 0, device: '1', platform: 'AMD Accelerated Parallel Processing', bus: '0000:03:00.0' });
|
||||||
const b = V.cardRow(bad);
|
const b = V.cardRow(bad);
|
||||||
assert.equal(b.unusable, true);
|
assert.equal(b.unusable, true);
|
||||||
|
|
@ -194,7 +195,7 @@ test('hot-plug (src/hotplug.rs): a removed card and a faulty card are shown as s
|
||||||
assert.equal(V.present(bad), false);
|
assert.equal(V.present(bad), false);
|
||||||
const t = V.toggle({ state: 'mining', paused: false, cards: [gone, bad] }, { synced: true }, {});
|
const t = V.toggle({ state: 'mining', paused: false, cards: [gone, bad] }, { synced: true }, {});
|
||||||
assert.equal(t.disabled, true);
|
assert.equal(t.disabled, true);
|
||||||
assert.equal(t.sub, 'no GPU this app can drive');
|
assert.equal(t.sub, 'no card this app can use');
|
||||||
const t2 = V.toggle({ state: 'mining', paused: false, cards: [card(), gone] }, { synced: true }, {});
|
const t2 = V.toggle({ state: 'mining', paused: false, cards: [card(), gone] }, { synced: true }, {});
|
||||||
assert.equal(t2.sub, 'mining on 1 of 1 card');
|
assert.equal(t2.sub, 'mining on 1 of 1 card');
|
||||||
});
|
});
|
||||||
|
|
@ -232,6 +233,7 @@ test('money: £ a day from watts at a price, nothing without one, the cells leav
|
||||||
assert.equal(apple.noReading, 'No power or temperature reading on Apple silicon');
|
assert.equal(apple.noReading, 'No power or temperature reading on Apple silicon');
|
||||||
assert.equal(V.cells(card({ kind: 'integrated', vendor: 'other', power_w: 0, temp_gpu: 0, eff_mhw: 0 }), 28).noReading, 'No power or temperature reading on an integrated GPU');
|
assert.equal(V.cells(card({ kind: 'integrated', vendor: 'other', power_w: 0, temp_gpu: 0, eff_mhw: 0 }), 28).noReading, 'No power or temperature reading on an integrated GPU');
|
||||||
assert.equal(V.wattsTotal([card(), card({ key: 'b', power_w: 100 }), card({ key: 'c', power_w: 50, removed_at: 5 })]), 510.2);
|
assert.equal(V.wattsTotal([card(), card({ key: 'b', power_w: 100 }), card({ key: 'c', power_w: 50, removed_at: 5 })]), 510.2);
|
||||||
|
assert.equal(V.wattsTotal([card(), card({ key: 'd', power_w: 48, state: 'off', enabled: false })]), 410.2, 'an idle card’s standby draw is not counted');
|
||||||
assert.equal(V.effText(V.effOf(card({ eff_mhw: 0.422 }))), '0.42 MH/W');
|
assert.equal(V.effText(V.effOf(card({ eff_mhw: 0.422 }))), '0.42 MH/W');
|
||||||
assert.equal(V.effText(V.effOf(card({ eff_mhw: 0, power_w: 0 }))), '');
|
assert.equal(V.effText(V.effOf(card({ eff_mhw: 0, power_w: 0 }))), '');
|
||||||
});
|
});
|
||||||
|
|
@ -249,7 +251,7 @@ test('the tune line: never run, running with a step and a bar, tuned with the po
|
||||||
assert.equal(tuned.kind, 'tuned'); assert.equal(tuned.note, '122.3 MH/s at 290 W (0.422 MH/W)'); assert.equal(tuned.button, 'tune');
|
assert.equal(tuned.kind, 'tuned'); assert.equal(tuned.note, '122.3 MH/s at 290 W (0.422 MH/W)'); assert.equal(tuned.button, 'tune');
|
||||||
assert.match(tuned.text, /^Tuned 2 h ago · 2470 MHz at 100% · next check /);
|
assert.match(tuned.text, /^Tuned 2 h ago · 2470 MHz at 100% · next check /);
|
||||||
const prior = V.tuneWords(nv({ tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'confirm', tune_clock_mhz: 2470, sweep_pct: 100, sweep_at: NOW - 120 }), settings(), NOW);
|
const prior = V.tuneWords(nv({ tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'confirm', tune_clock_mhz: 2470, sweep_pct: 100, sweep_at: NOW - 120 }), settings(), NOW);
|
||||||
assert.match(prior.text, /^Tuned 2 min ago from the fleet prior, confirmed · 2470 MHz at 100%/);
|
assert.match(prior.text, /^Tuned 2 min ago from what other miners found, confirmed · 2470 MHz at 100%/);
|
||||||
const measured = V.tuneWords(nv({ tune_line: 'Tuned: 26.7 MH/s at 38 W (0.703 MH/W)', tune_source: 'baseline', tune_control: false, sweep_at: NOW - 600, sweep_note: 'measure only on Apple silicon: the system sets the clocks and the power; no control exposed' }), settings(), NOW);
|
const measured = V.tuneWords(nv({ tune_line: 'Tuned: 26.7 MH/s at 38 W (0.703 MH/W)', tune_source: 'baseline', tune_control: false, sweep_at: NOW - 600, sweep_note: 'measure only on Apple silicon: the system sets the clocks and the power; no control exposed' }), settings(), NOW);
|
||||||
assert.equal(measured.kind, 'measured'); assert.equal(measured.text, 'Measured 26.7 MH/s at 38 W (0.703 MH/W) as it runs, 10 min ago');
|
assert.equal(measured.kind, 'measured'); assert.equal(measured.text, 'Measured 26.7 MH/s at 38 W (0.703 MH/W) as it runs, 10 min ago');
|
||||||
assert.equal(measured.note, 'Measured only on Apple silicon: the system sets the clocks and the power; no control exposed');
|
assert.equal(measured.note, 'Measured only on Apple silicon: the system sets the clocks and the power; no control exposed');
|
||||||
|
|
@ -279,7 +281,7 @@ test('the next check is a weekday inside a week, a date beyond it, due now when
|
||||||
test('the Tuning card schedule and the goal consequence', () => {
|
test('the Tuning card schedule and the goal consequence', () => {
|
||||||
const nv = (over) => card({ sweep_at: 0, tune_steps: 0, ...over });
|
const nv = (over) => card({ sweep_at: 0, tune_steps: 0, ...over });
|
||||||
assert.equal(V.schedule([nv()], settings(), NOW), 'Not tuned yet: the first tune starts 2 min into steady mining.');
|
assert.equal(V.schedule([nv()], settings(), NOW), 'Not tuned yet: the first tune starts 2 min into steady mining.');
|
||||||
assert.equal(V.schedule([nv()], settings({ sweep: false }), NOW), 'Ember Tune is off. Tune and Tune all still work by hand.');
|
assert.equal(V.schedule([nv()], settings({ sweep: false }), NOW), 'Ember Tune is off. Tune and Tune all still work when you press them.');
|
||||||
assert.equal(V.schedule([nv({ state: 'tuning', tune_step: 2, tune_steps: 9, tune_eta_s: 500 })], settings(), NOW), 'Tuning now: NVIDIA GeForce RTX 5090, step 2 of 9, mining again in about 8 min.');
|
assert.equal(V.schedule([nv({ state: 'tuning', tune_step: 2, tune_steps: 9, tune_eta_s: 500 })], settings(), NOW), 'Tuning now: NVIDIA GeForce RTX 5090, step 2 of 9, mining again in about 8 min.');
|
||||||
assert.match(V.schedule([nv({ sweep_at: NOW - 7200 }), nv({ key: 'b', name: 'B', sweep_at: NOW - 60 })], settings(), NOW), /^Last tune 1 min ago \(B\) · next check /);
|
assert.match(V.schedule([nv({ sweep_at: NOW - 7200 }), nv({ key: 'b', name: 'B', sweep_at: NOW - 60 })], settings(), NOW), /^Last tune 1 min ago \(B\) · next check /);
|
||||||
assert.equal(V.schedule([], settings(), NOW), 'No card here can be tuned.');
|
assert.equal(V.schedule([], settings(), NOW), 'No card here can be tuned.');
|
||||||
|
|
@ -296,7 +298,7 @@ test('the prove tier sentence per card, and the counts in one line', () => {
|
||||||
assert.equal(V.proveTier(card()), 'NVIDIA GeForce RTX 5090: proves while it mines (32 GB; a full shard needs 20.4 GB).');
|
assert.equal(V.proveTier(card()), 'NVIDIA GeForce RTX 5090: proves while it mines (32 GB; a full shard needs 20.4 GB).');
|
||||||
assert.equal(V.proveTier(card({ name: 'NVIDIA GeForce RTX 4070', vram_mb: 12288 })), 'NVIDIA GeForce RTX 4070: cannot prove a full shard (12 GB; a full shard needs 20.4 GB).');
|
assert.equal(V.proveTier(card({ name: 'NVIDIA GeForce RTX 4070', vram_mb: 12288 })), 'NVIDIA GeForce RTX 4070: cannot prove a full shard (12 GB; a full shard needs 20.4 GB).');
|
||||||
assert.equal(V.proveTier(card({ name: 'Apple M5 Max', vendor: 'apple' })), 'Apple M5 Max: proves on the CPU, slowly.');
|
assert.equal(V.proveTier(card({ name: 'Apple M5 Max', vendor: 'apple' })), 'Apple M5 Max: proves on the CPU, slowly.');
|
||||||
assert.equal(V.proveTier(card({ name: 'AMD Radeon RX 9070 XT', vendor: 'amd', vram_mb: 16384 })), 'AMD Radeon RX 9070 XT: cannot prove yet (the prover is CUDA only).');
|
assert.equal(V.proveTier(card({ name: 'AMD Radeon RX 9070 XT', vendor: 'amd', vram_mb: 16384 })), 'AMD Radeon RX 9070 XT: cannot prove yet (proving needs an NVIDIA card).');
|
||||||
assert.equal(V.proveLine({ assigned: 0, submitted: 0, paid: 0, paid_wei: '0' }, false, true), 'Off · not proving · 0 assigned · 0 proven · 0 paid · 0.0000 IGN');
|
assert.equal(V.proveLine({ assigned: 0, submitted: 0, paid: 0, paid_wei: '0' }, false, true), 'Off · not proving · 0 assigned · 0 proven · 0 paid · 0.0000 IGN');
|
||||||
assert.equal(V.proveLine({ status: 'idle', assigned: 3, submitted: 2, paid: 1, paid_wei: '1500000000000000000', available: true, enabled: true }, true, true), 'Idle · nothing assigned · 3 assigned · 2 proven · 1 paid · 1.5000 IGN');
|
assert.equal(V.proveLine({ status: 'idle', assigned: 3, submitted: 2, paid: 1, paid_wei: '1500000000000000000', available: true, enabled: true }, true, true), 'Idle · nothing assigned · 3 assigned · 2 proven · 1 paid · 1.5000 IGN');
|
||||||
});
|
});
|
||||||
|
|
@ -310,8 +312,352 @@ test('the node line, the activity line, the asks', () => {
|
||||||
assert.equal(V.eventLine('consensus parameters from the signed manifest: {"difficulty_v2_activation_daa":33000}'), 'consensus parameters from the signed manifest (details in the log)');
|
assert.equal(V.eventLine('consensus parameters from the signed manifest: {"difficulty_v2_activation_daa":33000}'), 'consensus parameters from the signed manifest (details in the log)');
|
||||||
assert.equal(V.eventLine('node synced: 151512 blocks, 6 peer(s)'), 'node synced: 151512 blocks, 6 peer(s)');
|
assert.equal(V.eventLine('node synced: 151512 blocks, 6 peer(s)'), 'node synced: 151512 blocks, 6 peer(s)');
|
||||||
assert.equal(V.eventLine('a '.repeat(100), 40).length <= 41, true);
|
assert.equal(V.eventLine('a '.repeat(100), 40).length <= 41, true);
|
||||||
assert.equal(V.addressAsk('0x1234567890abcdef1234567890abcdef12345678'), 'Pay 0x1234…5678 from the next block? The miner restarts.');
|
assert.equal(V.addressAsk('0x1234567890abcdef1234567890abcdef12345678'), 'Pay 0x1234…5678 from the next block? Mining restarts.');
|
||||||
assert.match(V.trustAsk(true), /^Include proof records the node never checked\? Devnet only; the node restarts\.$/);
|
assert.match(V.trustAsk(true), /^Include proofs the node never checked\? Devnet only\. The node restarts\.$/);
|
||||||
assert.match(V.trustAsk(false), /^Verify proof records again\?/);
|
assert.match(V.trustAsk(false), /^Check proofs again\?/);
|
||||||
assert.equal(V.ago(30), 'just now'); assert.equal(V.ago(7200), '2 h ago'); assert.equal(V.ago(200000), '2 d ago');
|
assert.equal(V.ago(30), 'just now'); assert.equal(V.ago(7200), '2 h ago'); assert.equal(V.ago(200000), '2 d ago');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the activity feed and the pill speak in the user\'s voice; the engine line is kept as the tooltip', () => {
|
||||||
|
const pe = V.plainEvent('job update-now-0313-switch-d937c69d (Exec restart: re-read the manifest (exec_restart_number 27276) and restart the node with it) done after 0 min, exit 0, report uploaded: update check and install asked');
|
||||||
|
assert.equal(pe.text, 'A job from the team ran: update check, 0 min');
|
||||||
|
assert.match(pe.tech, /^job update-now/);
|
||||||
|
assert.equal(V.plainEvent('updated to Igneum Miner 0.3.14 from 0.3.13').text, 'Updated to 0.3.14');
|
||||||
|
assert.equal(V.plainEvent('consensus parameters from the signed manifest: {"difficulty_v2_activation_daa":33000}').text, 'Rule settings read from the team');
|
||||||
|
assert.equal(V.plainEvent('node synced: 151512 blocks, 6 peer(s)').text, 'Node synced: 151,512 blocks, 6 peers');
|
||||||
|
assert.equal(V.plainEvent('block accepted by the node (Apple M5 Max)').text, 'Block found by Apple M5 Max');
|
||||||
|
assert.equal(V.plainEvent('1 remote job received from Igneum').text, '1 job from the team received');
|
||||||
|
assert.equal(V.plainEvent('remote job: update check now; a newer version installs at once').text, 'The team asked for an update check');
|
||||||
|
assert.equal(V.plainEvent('a node already answers on 127.0.0.1:26610; using it (it is not stopped by this app)').text, 'Using the node already running on this machine');
|
||||||
|
assert.equal(V.plainEvent('igneumd exited with code 101 after 300 s; restarting').text, 'The node stopped (code 101). Restarting');
|
||||||
|
assert.equal(V.plainEvent('miner apple:0 exited with code 1; restarting in 8 s').text, 'A card stopped (code 1). Restarting in 8 s');
|
||||||
|
assert.equal(V.plainEvent('NVIDIA GeForce RTX 5090 kernel race: l128w16 at 122.3 MH/s (+4.1% over base, 6 variants, 40 s)').text, 'NVIDIA GeForce RTX 5090: fastest program chosen, 122.3 MH/s');
|
||||||
|
assert.equal(V.plainEvent('block 59199 shard 0 paid 0.0125 IGN').text, 'Proof paid: 0.0125 IGN');
|
||||||
|
assert.equal(V.plainEvent('rewards go to 0xdd442fcbb964a3afdc90d49b408e8dd296fa86e8').text, 'Rewards go to 0xdd44…86e8');
|
||||||
|
assert.equal(V.plainEvent('mining resumed').text, 'Mining resumed');
|
||||||
|
assert.equal(V.plainEvent('something new the engine says').text, 'Something new the engine says');
|
||||||
|
assert.equal(V.plainEvent('something new the engine says').tech, '');
|
||||||
|
const base = { setup_done: true, quitting: false, mining: { state: 'mining', hash_total: 510.6, cards: [] }, node: { state: 'synced' } };
|
||||||
|
assert.deepEqual(V.pill(base), { text: 'Mining · 511 MH/s', tone: 'on' });
|
||||||
|
assert.deepEqual(V.pill({ ...base, mining: { state: 'mining', hash_total: 17.04, cards: [] } }), { text: 'Mining · 17.0 MH/s', tone: 'on' });
|
||||||
|
assert.deepEqual(V.pill({ ...base, mining: { state: 'paused', hash_total: 0, cards: [] } }), { text: 'Paused', tone: '' });
|
||||||
|
assert.deepEqual(V.pill({ ...base, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'syncing' } }), { text: 'Syncing the node', tone: '' });
|
||||||
|
assert.deepEqual(V.pill({ ...base, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'restarting' } }), { text: 'Node restarting', tone: 'bad' });
|
||||||
|
assert.deepEqual(V.pill({ ...base, mining: { state: 'mining', hash_total: 1, cards: [{ state: 'tuning' }] } }), { text: 'Tuning', tone: 'on' });
|
||||||
|
assert.deepEqual(V.pill({ ...base, setup_done: false, detecting: true }), { text: 'Detecting cards', tone: '' });
|
||||||
|
assert.deepEqual(V.pill({ ...base, quitting: true }), { text: 'Stopping', tone: '' });
|
||||||
|
assert.deepEqual(V.pill({ ...base, mining: { state: 'idle', hash_total: 0, cards: [] } }), { text: 'Not mining', tone: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Ember on the row (miner-ui-4): the flame fill, before -> after, the saving, the sparkline points, the plain label', () => {
|
||||||
|
const curve = [{ clock_mhz: 0, power_pct: 100, watts: 374, mhs: 122.5, eff: 0.328 }, { clock_mhz: 0, power_pct: 80, watts: 302, mhs: 122.3, eff: 0.405 }, { clock_mhz: 0, power_pct: 60, watts: 228, mhs: 104.9, eff: 0.46, mark: 'hot' }, { clock_mhz: 2470, power_pct: 80, watts: 290, mhs: 122.3, eff: 0.422 }];
|
||||||
|
const tuned = card({ tune_line: 'Tuned: 122.3 MH/s at 290 W (0.422 MH/W)', tune_source: 'full', tune_control: true, tune_clock_mhz: 2470, sweep_pct: 80, sweep_at: NOW - 7200, sweep_watts: 290, sweep_mhs: 122.3, tune_curve: curve, sweep_state: 'idle', sweep_note: '' });
|
||||||
|
const e = V.ember(tuned, settings(), NOW);
|
||||||
|
assert.equal(e.kind, 'tuned'); assert.equal(e.fill, 1);
|
||||||
|
assert.equal(e.label, 'Tuned 2 h ago');
|
||||||
|
assert.match(e.sub, /^2470 MHz at 80% · next check /);
|
||||||
|
assert.equal(e.before, 374); assert.equal(e.after, 290); assert.equal(e.savingW, 84);
|
||||||
|
assert.equal(e.savingText, 'saves 84 W, 0.16% of rate');
|
||||||
|
assert.equal(e.points.length, 4); assert.equal(e.points.filter((p) => p.chosen).length, 1); assert.equal(e.points.filter((p) => p.marked).length, 1);
|
||||||
|
// Ember's own fields win over the curve (tune_before_watts / tune_before_mhs, step 0 of the last full plan)
|
||||||
|
const withFields = V.ember(card({ ...tuned, tune_before_watts: 380, tune_before_mhs: 122.4, tune_source: 'confirm' }), settings(), NOW);
|
||||||
|
assert.equal(withFields.before, 380); assert.equal(withFields.savingW, 90); assert.match(withFields.label, /from what other miners found$/);
|
||||||
|
// a confirm plan without the fields claims no saving (its curve's first row is the prior, not an untuned point)
|
||||||
|
assert.equal(V.ember(card({ ...tuned, tune_source: 'confirm' }), settings(), NOW).savingText, '');
|
||||||
|
assert.equal(V.ember(card({ ...tuned, tune_before_watts: 0 }), settings(), NOW).savingText, '', 'zero means never measured');
|
||||||
|
const running = V.ember(card({ state: 'tuning', tune_step: 4, tune_steps: 9, tune_eta_s: 410, sweep_state: 'running' }), settings(), NOW);
|
||||||
|
assert.equal(running.kind, 'running'); assert.equal(running.fill.toFixed(2), '0.33'); assert.equal(running.label, 'Tuning: step 4 of 9'); assert.equal(running.sub, 'about 7 min left');
|
||||||
|
const measured = V.ember(card({ tune_line: 'Measured: 122.3 MH/s at 410 W (0.298 MH/W)', tune_source: 'baseline', tune_control: false, sweep_at: NOW - 600, sweep_note: 'measure only until Power control is on in Settings (Windows asks for administrator rights once)', sweep_state: 'idle' }), settings({ power_control: false }), NOW);
|
||||||
|
assert.equal(measured.kind, 'measured'); assert.equal(measured.label, 'Measured, not tuned'); assert.equal(measured.sub, 'Power control is off'); assert.equal(measured.fill, 0.5);
|
||||||
|
const idle = V.ember(card({ sweep_state: 'idle', sweep_note: '', tune_line: '', sweep_at: 0 }), settings(), NOW);
|
||||||
|
assert.equal(idle.kind, 'idle'); assert.equal(idle.fill, 0); assert.equal(idle.label, 'Not tuned yet'); assert.equal(idle.sub, 'starts after 2 min of steady mining');
|
||||||
|
const off = V.ember(card({ sweep_state: 'idle', sweep_note: '', tune_line: '', sweep_at: 0 }), settings({ sweep: false }), NOW);
|
||||||
|
assert.equal(off.label, 'Not tuned: Ember Tune is off'); assert.equal(off.sub, 'Tune starts one by hand');
|
||||||
|
assert.equal(V.ember(card({ vendor: 'other', kind: 'integrated', sweep_supported: false }), settings(), NOW).kind, 'none');
|
||||||
|
assert.equal(V.fleetSaving([tuned, card({ key: 'b', sweep_state: 'idle', tune_line: '', sweep_at: 0 })], settings(), NOW, 28), 'Ember saves 84 W, £0.56 a day across 1 tuned card');
|
||||||
|
assert.equal(V.fleetSaving([card({ sweep_state: 'idle', tune_line: '', sweep_at: 0 })], settings(), NOW, 28), '');
|
||||||
|
assert.deepEqual(V.beforeOf({ tune_before_watts: 0, tune_curve: curve }), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the 0.3.16 engine fields (ember-tune fd03f35): the balance row, the fleet totals, the chain scene note', () => {
|
||||||
|
assert.deepEqual(V.balanceWords({}, NOW), { text: '', sub: '', pounds: null }, 'an older engine has no balance field: the row stays hidden');
|
||||||
|
const unread = V.balanceWords({ balance_wei: null, balance_age_s: -1, balance_note: '', price_gbp_per_ign: null }, NOW);
|
||||||
|
assert.equal(unread.text, 'not read yet'); assert.equal(unread.pounds, null);
|
||||||
|
const read = V.balanceWords({ balance_wei: '12345600000000000000', balance_age_s: 20, balance_note: '', price_gbp_per_ign: null }, NOW);
|
||||||
|
assert.equal(read.text, '12.3456 IGN'); assert.equal(read.sub, 'in the wallet · read just now'); assert.equal(read.pounds, null);
|
||||||
|
const priced = V.balanceWords({ balance_wei: '12345600000000000000', balance_age_s: 90, balance_note: '', price_gbp_per_ign: 0.5 }, NOW);
|
||||||
|
assert.equal(priced.pounds.toFixed(2), '6.17'); assert.equal(priced.sub, 'in the wallet · read 1 min ago');
|
||||||
|
assert.equal(V.balanceWords({ balance_wei: '5000000000000000000000', balance_age_s: 3, balance_note: 'the node answered 0x for the balance, not a quantity' }, NOW).sub, 'in the wallet · read just now · the node answered 0x for the balance, not a quantity');
|
||||||
|
assert.equal(V.balanceWords({ balance_wei: '5000000000000000000000', balance_age_s: 3 }, NOW).text, '5,000 IGN');
|
||||||
|
assert.equal(V.ign('1500000000000000000'), 1.5); assert.equal(V.ign('x'), null);
|
||||||
|
const cards = [card(), card({ key: 'b', power_w: 100 })];
|
||||||
|
assert.equal(V.fleetWatts({ watts_total: 0 }, cards), 510.2, 'no engine figure: the UI sums the cards');
|
||||||
|
assert.equal(V.fleetWatts({ watts_total: 629 }, cards), 629, 'the engine figure wins');
|
||||||
|
assert.equal(V.fleetPounds({ watts_total: 629, pounds_per_day: 4.23 }, cards, 28), 4.23);
|
||||||
|
assert.equal(V.fleetPounds({ watts_total: 629, pounds_per_day: 0 }, cards, 28).toFixed(2), '4.23', 'no engine £: computed from the watts and the price');
|
||||||
|
assert.equal(V.fleetPounds({}, cards, 0), null);
|
||||||
|
assert.equal(V.liveNote({ ok: true, source: 'node', blocks: [] }), 'From your node. Your blocks are ringed.');
|
||||||
|
assert.equal(V.liveNote({ ok: true, source: 'site', blocks: [] }), 'From Igneum’s observer. Your blocks are ringed.');
|
||||||
|
assert.equal(V.liveNote({ ok: true, state: { source: 'site' } }), 'From Igneum’s observer. Your blocks are ringed.', 'the 0.3.15 engine carries the source under state');
|
||||||
|
assert.equal(V.liveNote({ ok: false, pending: true, source: 'site' }), 'connecting');
|
||||||
|
assert.equal(V.liveNote(null), '');
|
||||||
|
const mineCards = [card({ ids: ['8fafda27', '1b2c3d4e'] })];
|
||||||
|
assert.equal(V.isMine('8fafda27', mineCards), true, 'the site: the first 8 hex of the vote key hash');
|
||||||
|
assert.equal(V.isMine('8fafda..e01f22', mineCards), true, 'the 0.3.16 engine: head6..tail6 of the same hash');
|
||||||
|
assert.equal(V.isMine('8fafda27a9c1', mineCards), true, 'a longer prefix of the same hash');
|
||||||
|
assert.equal(V.isMine('0x1B2C3D4E', mineCards), true);
|
||||||
|
assert.equal(V.isMine('8fafdb..e01f22', mineCards), false, 'one hex off');
|
||||||
|
assert.equal(V.isMine('deadbeef', mineCards), false);
|
||||||
|
assert.equal(V.isMine('8faf', mineCards), false, 'under 6 hex never matches');
|
||||||
|
assert.equal(V.isMine('', mineCards), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('N4 (6 October 2026): a frozen tip reads behind, never synced; the tip age is on the node line', () => {
|
||||||
|
const n = (over) => ({ state: 'synced', blocks: 133000, headers: 133000, peers: 1, daa: 140000, last_reading_age_s: 4, tip_age_s: 3, message: '', restart_in_s: 0, ...over });
|
||||||
|
const ok = V.nodeLine(n(), { severity: 'none' }, '');
|
||||||
|
assert.equal(ok.text, 'Node synced · 1 peer · 133,000 blocks · last block 3 s ago'); assert.equal(ok.tone, 'ok');
|
||||||
|
assert.match(ok.sub, /Last block 3 s ago/);
|
||||||
|
const behind = V.nodeLine(n({ state: 'behind', synced: false, tip_age_s: 3180, sync_cause: 'frozen' }), { severity: 'none' }, '');
|
||||||
|
assert.equal(behind.text, 'Node behind · 1 peer · 133,000 blocks · last block 53 min ago'); assert.equal(behind.tone, 'bad');
|
||||||
|
assert.match(behind.sub, /^No new block for 53 min\. The chain may have moved on without this node\./);
|
||||||
|
const none = V.nodeLine(n({ state: 'no peers', synced: false, peers: 0, tip_age_s: 500 }), { severity: 'none' }, '');
|
||||||
|
assert.equal(none.text, 'No peers · 133,000 blocks'.replace('No peers', 'Node no peers')); assert.equal(none.tone, 'bad');
|
||||||
|
assert.match(none.sub, /^No other node on our chain is connected/);
|
||||||
|
// an older node without the age keeps the old words
|
||||||
|
assert.equal(V.nodeLine(n({ tip_age_s: -1 }), { severity: 'none' }, '').text, 'Node synced · 1 peer · 133,000 blocks');
|
||||||
|
assert.equal(V.tipAge(12), '12 s'); assert.equal(V.tipAge(240), '4 min'); assert.equal(V.tipAge(3180), '53 min'); assert.equal(V.tipAge(7300), '2 h');
|
||||||
|
const base = { setup_done: true, quitting: false, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'behind' } };
|
||||||
|
assert.deepEqual(V.pill(base), { text: 'Node behind', tone: 'bad' });
|
||||||
|
assert.deepEqual(V.pill({ ...base, node: { state: 'no peers' } }), { text: 'No peers', tone: 'bad' });
|
||||||
|
assert.equal(V.toggle({ state: 'waiting', paused: false, cards: [card({ state: 'waiting' })] }, { synced: false, state: 'behind' }, {}).sub, 'waiting: the node is behind the chain');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('not merging (0.3.18 design note): behind with a moving tip, the miner held', () => {
|
||||||
|
const w = V.nodeWords({ state: 'behind', sync_cause: 'not merging', tip_age_s: 3, message: 'behind: your blocks are not merging into the network', blocks: 1, headers: 1, peers: 2 }, { severity: 'none' }, '');
|
||||||
|
assert.equal(w.word, 'behind'); assert.equal(w.tone, 'bad'); assert.match(w.line, /not merging into the network/); assert.doesNotMatch(w.line, /No new block/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finality paused (ember-tune 1357d280): the words, no lock called final while paused', () => {
|
||||||
|
const p = V.finalityWords({ paused: true, last_lock: 412, age_s: 1200, line: 'Finality paused since 18:39 UTC: under two thirds of the weight is signing' });
|
||||||
|
assert.equal(p.paused, true); assert.equal(p.age, 'paused'); assert.equal(p.note, 'Finality paused since 18:39 UTC: under two thirds of the weight is signing');
|
||||||
|
assert.equal(/\bfinal\b/i.test(p.note), false);
|
||||||
|
assert.equal(V.finalityWords({ paused: true, last_lock: 0, age_s: 0 }).note, 'Finality paused: under two thirds of the weight is signing');
|
||||||
|
const ok = V.finalityWords({ paused: false, last_lock: 412, age_s: 90 });
|
||||||
|
assert.equal(ok.paused, false); assert.equal(ok.age, '1 min ago'); assert.match(ok.note, /never be undone/);
|
||||||
|
const none = V.finalityWords({ paused: false, last_lock: 0, message: 'waiting for the miner' });
|
||||||
|
assert.equal(none.age, 'n/a'); assert.equal(none.note, 'waiting for the miner');
|
||||||
|
// N5: a node built without the mining engine is a fault
|
||||||
|
assert.equal(V.nodeWords({ state: 'stub', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '').word, 'stub engine');
|
||||||
|
assert.deepEqual(V.pill({ setup_done: true, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'stub' } }), { text: 'Node fault', tone: 'bad' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('whose node (publish 2): the app’s own, another node on this machine checked or unchecked, or none when the ports are taken by other rules', () => {
|
||||||
|
assert.deepEqual(V.nodeSource({ source: 'app' }), { text: 'this app’s own node', sub: '' });
|
||||||
|
assert.deepEqual(V.nodeSource({ source: 'external', rules_check: 'match' }), { text: 'another node on this machine', sub: 'same network and rules, checked' });
|
||||||
|
assert.deepEqual(V.nodeSource({ source: 'external', rules_check: 'unknown' }), { text: 'another node on this machine', sub: 'its rules could not be checked (an older node)' });
|
||||||
|
assert.deepEqual(V.nodeSource({ source: 'none', port_note: 'Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)' }), { text: 'no node', sub: 'Node not started: port 26611 is taken by a node on another network (chain id 7778, ours 7777)' });
|
||||||
|
assert.deepEqual(V.nodeSource({}), { text: '', sub: '' });
|
||||||
|
// 7 October 2026: the mode is re-decided; after the other node leaves for 60 s the app runs its own and says why
|
||||||
|
assert.deepEqual(V.nodeSource({ source: 'app', mode: 'own', mode_reason: 'the node this app was reading left port 26611 for 60 s, so the app started its own node' }), { text: 'this app’s own node', sub: 'the node this app was reading left port 26611 for 60 s, so the app started its own node' });
|
||||||
|
const gone = V.nodeWords({ state: 'stopped', source: 'external', message: 'the other node on port 26611 went away; this app starts its own node in 45 s unless it comes back', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '');
|
||||||
|
assert.equal(gone.word, 'stopped'); assert.match(gone.line, /starts its own node in 45 s/);
|
||||||
|
const taken = V.nodeWords({ state: 'stopped', source: 'none', message: 'Node not started: port 26611 is taken by a node on other rules (fees_v1_activation_daa 200000, ours 210000)', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '');
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -209,6 +209,9 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
|
||||||
}
|
}
|
||||||
} else if line.hasPrefix("STATE ") {
|
} else if line.hasPrefix("STATE ") {
|
||||||
applyState(String(line.dropFirst(6)))
|
applyState(String(line.dropFirst(6)))
|
||||||
|
} else if line.hasPrefix("SHOT ") {
|
||||||
|
// Miner UI 4 (b): the engine's POST /api/shot asks for a PNG of what the window shows, at this path
|
||||||
|
shotTo(String(line.dropFirst(5)).trimmingCharacters(in: .whitespaces))
|
||||||
} else if line.hasPrefix("FATAL ") {
|
} else if line.hasPrefix("FATAL ") {
|
||||||
fail(String(line.dropFirst(6)))
|
fail(String(line.dropFirst(6)))
|
||||||
} else if line == "EXIT" {
|
} else if line == "EXIT" {
|
||||||
|
|
@ -322,6 +325,19 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
|
||||||
let a = NSAlert(); a.messageText = "Igneum Miner"; a.informativeText = message; a.runModal(); completionHandler()
|
let a = NSAlert(); a.messageText = "Igneum Miner"; a.informativeText = message; a.runModal(); completionHandler()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- a screenshot on request (the SHOT line), the app keeps running ----
|
||||||
|
func shotTo(_ path: String) {
|
||||||
|
let conf = WKSnapshotConfiguration()
|
||||||
|
conf.rect = web.bounds
|
||||||
|
web.takeSnapshot(with: conf) { image, error in
|
||||||
|
guard let img = image, let tiff = img.tiffRepresentation, let rep = NSBitmapImageRep(data: tiff), let png = rep.representation(using: .png, properties: [:]) else {
|
||||||
|
FileHandle.standardError.write("shot failed: \(error?.localizedDescription ?? "no image")\n".data(using: .utf8)!)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
do { try png.write(to: URL(fileURLWithPath: path)) } catch { FileHandle.standardError.write("shot: could not write \(path): \(error)\n".data(using: .utf8)!) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- snapshot ----
|
// ---- snapshot ----
|
||||||
func snapshot(to path: String) {
|
func snapshot(to path: String) {
|
||||||
if let js = ProcessInfo.processInfo.environment["IGNEUM_PROBE"] {
|
if let js = ProcessInfo.processInfo.environment["IGNEUM_PROBE"] {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
#endif
|
#endif
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
#include <shellapi.h>
|
#include <shellapi.h>
|
||||||
|
#include <shlwapi.h>
|
||||||
#include <dbt.h>
|
#include <dbt.h>
|
||||||
#include <wrl.h>
|
#include <wrl.h>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
@ -39,6 +40,13 @@ static HWND g_hwnd = nullptr;
|
||||||
static HANDLE g_engine = nullptr, g_engineIn = nullptr, g_engineOut = nullptr;
|
static HANDLE g_engine = nullptr, g_engineIn = nullptr, g_engineOut = nullptr;
|
||||||
static ComPtr<ICoreWebView2Controller> g_controller;
|
static ComPtr<ICoreWebView2Controller> g_controller;
|
||||||
static ComPtr<ICoreWebView2> g_webview;
|
static ComPtr<ICoreWebView2> g_webview;
|
||||||
|
// a PNG of the web view at `path` (the SHOT line); the stream is released by the completion handler
|
||||||
|
static void capturePreview(const std::wstring& path) {
|
||||||
|
if (!g_webview) return;
|
||||||
|
ComPtr<IStream> stream;
|
||||||
|
if (FAILED(SHCreateStreamOnFileEx(path.c_str(), STGM_CREATE | STGM_WRITE | STGM_SHARE_EXCLUSIVE, FILE_ATTRIBUTE_NORMAL, TRUE, nullptr, &stream))) return;
|
||||||
|
g_webview->CapturePreview(COREWEBVIEW2_CAPTURE_PREVIEW_IMAGE_FORMAT_PNG, stream.Get(), Callback<ICoreWebView2CapturePreviewCompletedHandler>([stream](HRESULT) -> HRESULT { return S_OK; }).Get());
|
||||||
|
}
|
||||||
static std::wstring g_url, g_status = L"starting the engine";
|
static std::wstring g_url, g_status = L"starting the engine";
|
||||||
static bool g_paused = false, g_quitting = false, g_exited = false, g_webviewOk = false, g_hintShown = false;
|
static bool g_paused = false, g_quitting = false, g_exited = false, g_webviewOk = false, g_hintShown = false;
|
||||||
static NOTIFYICONDATAW g_nid = {};
|
static NOTIFYICONDATAW g_nid = {};
|
||||||
|
|
@ -308,6 +316,9 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
||||||
applyState(line->substr(6));
|
applyState(line->substr(6));
|
||||||
} else if (line->rfind("ELEVATE ", 0) == 0) {
|
} else if (line->rfind("ELEVATE ", 0) == 0) {
|
||||||
runElevated(widen(line->substr(8)));
|
runElevated(widen(line->substr(8)));
|
||||||
|
} else if (line->rfind("SHOT ", 0) == 0) {
|
||||||
|
// Miner UI 4 (b): the engine's POST /api/shot asks for a PNG of what the window shows, at this path
|
||||||
|
capturePreview(widen(line->substr(5)));
|
||||||
} else if (line->rfind("FATAL ", 0) == 0) {
|
} else if (line->rfind("FATAL ", 0) == 0) {
|
||||||
g_status = widen(line->substr(6));
|
g_status = widen(line->substr(6));
|
||||||
repaintStatus();
|
repaintStatus();
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,6 @@
|
||||||
// packaging/windows/Igneum-Miner.iss when the app version moves. Include guards, not #pragma once: rc.exe reads it too.
|
// packaging/windows/Igneum-Miner.iss when the app version moves. Include guards, not #pragma once: rc.exe reads it too.
|
||||||
#ifndef IGNEUM_HOST_VERSION_H
|
#ifndef IGNEUM_HOST_VERSION_H
|
||||||
#define IGNEUM_HOST_VERSION_H
|
#define IGNEUM_HOST_VERSION_H
|
||||||
#define IGNEUM_HOST_VERSION_STR "0.3.14"
|
#define IGNEUM_HOST_VERSION_STR "0.3.19"
|
||||||
#define IGNEUM_HOST_VERSION_RC 0,3,14,0
|
#define IGNEUM_HOST_VERSION_RC 0,3,19,0
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -2544,6 +2544,35 @@ task now runs ...Programs\Igneum Miner\igneum-app.exe --power-helper"), then the
|
||||||
task with no prompt ("-pl 460: set to 460.00 W from 575.00 W", "-pl 160: set to 160.00 W from 100.00 W"). Task
|
task with no prompt ("-pl 460: set to 460.00 W from 575.00 W", "-pl 160: set to 160.00 W from 100.00 W"). Task
|
||||||
Running, Highest, user Admin. Cards: 5090 221 W at 1845 MHz, 4070 75.8 W at 1860 MHz, 9070 XT 202 W, all mining
|
Running, Highest, user Admin. Cards: 5090 221 W at 1845 MHz, 4070 75.8 W at 1860 MHz, 9070 XT 202 W, all mining
|
||||||
through the installed app. Ember closed 16:53Z.
|
through the installed app. Ember closed 16:53Z.
|
||||||
|
### 6 October 2026, 17:55Z to 17:57Z, the first segment above the restart cut from a snapshot-restored node on the new export (Mac)
|
||||||
|
|
||||||
|
A copy of node 1's data dir started on the shipper's 0.3.14 Mac binary (target-0314, release-0.3.14-node 4c6b129d) with
|
||||||
|
`--igneum-exec-snapshot=node1-copy-snapshot.bin,0xac101f13...` (the hands' recovery file, tip 130,272) and node 1 as its
|
||||||
|
peer: IBD of 8,049 blocks in 29 s, the executor resumed from the file and reached the tip 140,023 with no restart replay.
|
||||||
|
`igneum_exportSegments [139951, 139959]` answered in under 0.1 s with 9 segments, `preState` 79 accounts, `execRestart`
|
||||||
|
27,276. The branch exporter (exec-app-0314 bfebff1) cut all 8 blocks of segment 139,952..139,959 in under 0.1 s each:
|
||||||
|
"replayed 8 segments from the account dump; every state root equals the node's". The first block's fixture carries
|
||||||
|
pre-state root 0x64304441... and post-state root 0x60d60bba..., equal to node 1's eth_getBlockByNumber roots at 139,951
|
||||||
|
and 139,952 (an independent node). Consequence: a prover on a snapshot-restored node (every hand, nine fleet boxes) cuts a
|
||||||
|
provable fixture again with the 0.3.14 app and exporter; what is left for a paid record is the proof itself (the PCs or
|
||||||
|
the fleet, not this Mac).
|
||||||
|
|
||||||
|
### 6 October 2026, 18:11Z to 18:13Z, the PC shape over p2p (Mac, `tools/lock/with-lock.sh run`)
|
||||||
|
|
||||||
|
Fork 1255c0f9. `node tools/exec-sync/net.mjs`: 18/18 in 117.5 s. Case 7: a node whose data dir holds the tip-0 pair the 0.3.13
|
||||||
|
genesis replay left behind, with `--igneum-exec-snapshot=peer`, sets the pair aside (.bin.tip0), asks A over p2p and loads
|
||||||
|
A's state with no hand action; its state root at 60 equals A's. `reorg.mjs` 18/18 in 262.1 s on the same binary; the exec
|
||||||
|
suite 20. Consequence: a PC or a box left with the tip-0 pair recovers on its own once a hand serves (the 5-minute
|
||||||
|
no-progress rule fires the ask even when the error text is new); until 0.3.14.1 the recovery file by hand is the way.
|
||||||
|
|
||||||
|
## 6 October 2026, 18:18Z: the shipped 0.3.14 app asked to tune itself on PC 1 (ember-installed-pc1-1): FAILED, no tune ran
|
||||||
|
|
||||||
|
Gate ok (0.3.14, Power control on, the task on the installed exe). Every card's "after" was its 17:55Z failure from
|
||||||
|
the app's own first tune: 5090 and 4070 "did not take within 30 s (card reports 460 / 160 W, acknowledged true)",
|
||||||
|
9070 XT "the card refused the setting"; the three "Tune now" requests sat queued behind the hour's back-off. Five
|
||||||
|
causes (A to E) and their fixes in the plan's 0.3.14 window section; the window repeats on 0.3.15. The cards kept
|
||||||
|
mining at the driver's held locks: 5090 222.9 W at 1845 MHz, 4070 75.9 W at 1860 MHz, 9070 XT 201 W. Lever-2 TUNING
|
||||||
|
records during the window: 5090 135.6 MH/s at 236 W (0.574 MH/W), 4070 31.1 MH/s at 79 W (0.393).
|
||||||
|
|
||||||
## Prover tiers on real cards: the rented fleet, 6 October 2026 (branch gpu-fleet)
|
## Prover tiers on real cards: the rented fleet, 6 October 2026 (branch gpu-fleet)
|
||||||
|
|
||||||
|
|
|
||||||
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).
|
||||||
|
|
@ -296,8 +296,38 @@ record. Known in 0.3.13 on PC 1 until 0.3.14: the rows show the app's own earlie
|
||||||
to receive the run's result), and the 80% caps are the app's setting; the draw is the tuned one regardless because
|
to receive the run's result), and the 80% caps are the app's setting; the draw is the tuned one regardless because
|
||||||
the driver holds the clock locks and the caps do not bind.
|
the driver holds the clock locks and the caps do not bind.
|
||||||
|
|
||||||
|
### The 0.3.14 window (6 October 2026, 18:18 to 18:27Z): no tune ran; five causes
|
||||||
|
|
||||||
|
The shipped 0.3.14 app, Power control on, the task registered, asked over its own API to tune each card
|
||||||
|
(`relay/playbooks/ember-installed-tune-pc1.ps1`). From its log and helper.log:
|
||||||
|
|
||||||
|
| | When (UTC) | What | Cause | Fix (0.3.15) |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| C | 17:55:25 | the app's own first 5090 tune wrote its first request in the second the helper started; the helper skipped it as "present at my start"; 30 s later "did not take (card reports 460 W, acknowledged true)" | the engine wrote before the helper was up | the helper writes a heartbeat (`helper.alive`) every poll; the engine starts the task and waits for a fresh heartbeat BEFORE writing (`powertask::ensure_running`) |
|
||||||
|
| A | 17:55:55 | the 4070's helper start read the stale `quit` from the 5090's abort and exited in the same second | quit/remove matched before the sequence check | the helper acts only on lines added after its start (`commands_after`) |
|
||||||
|
| D | 17:57:27 | the 9070 XT, measure only ("igneum-gpu-telemetry gave no tune line": the 3.0 coordinator's job was on the ADLX helper), was still sent a set; refused; "tuning stopped" | a measure-only card was set | `ember::may_set`: measure only sets nothing |
|
||||||
|
| E | 18:18 to 18:27 | three "Tune now" requests sat "queued" behind the hour's back-off from the 17:55 failures; none started | `sweep_retry` blocked a forced request silently | a forced request clears the back-off; a held row says "tuning: retry in N min (why)" (`ember::retry_note`) |
|
||||||
|
| B | (latent) | the engine's helper flag stayed true after the helper's idle exit; a later step would have been acknowledged after a blind 4 s sleep | a flag instead of a fact | the heartbeat decides; the acknowledgement is the helper's own log line for the sequence (up to 15 s), a missing line fails the step with that reason |
|
||||||
|
|
||||||
|
What a user on 0.3.14 sees: Power control on, no prompt, and no card tunes (the rows keep the baseline or
|
||||||
|
"tuning stopped"); the lever-2 TUNING records still flow. 0.3.15 closes it; the window repeats there.
|
||||||
|
|
||||||
## 8a. Next-cut notes (for the 0.3.12 shipper)
|
## 8a. Next-cut notes (for the 0.3.12 shipper)
|
||||||
|
|
||||||
|
For 0.3.16 (Miner UI 4's Cards tab, Ember as its second layer), on ember-tune past the 0.3.15 tip 7fadd4c:
|
||||||
|
|
||||||
|
| Commit | What | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| (past 7fadd4c) | a per-card goal: `POST /api/tune/goal {key, goal}` (efficiency, balanced, rate; "" or "global" clears), persisted as `CardPref.tune_goal`, echoed as `card.tune_goal` ("" = the global goal), resolved at the tune's start (`ember::goal_for`); the untuned point of the last full plan on the row: `card.tune_before_watts`, `card.tune_before_mhs` (its step 0, kept across confirm plans; `CardPref.sweep_before_*`), so the row can read "saves 84 W, 0.15% of rate" against `sweep_watts` / `sweep_mhs`; Retune = `POST /api/sweep/start {key}` (forced: clears a back-off since 0.3.15) | ember.rs, engine.rs, server.rs, config.rs, state.rs, hotplug.rs + test |
|
||||||
|
|
||||||
|
For the 0.3.15 cut (6 October 2026 evening), on ember-tune:
|
||||||
|
|
||||||
|
| Commit | What | Where |
|
||||||
|
|---|---|---|
|
||||||
|
| a8cab23 | (A) the helper acts only on lines added after its start; the acknowledgement is the helper's log line for the sequence | powertask.rs `commands_after` + test, engine.rs |
|
||||||
|
| (tip) | (B, C) the heartbeat, `ensure_running` before every task-path write; (D) `may_set`; (E) the back-off yields to a forced request, `retry_note`; the window playbook's gate at 0.3.15; `ember-tune-pc1.ps1` deleted (superseded by the installed-tune playbook) | powertask.rs, engine.rs, ember.rs + tests, relay/playbooks |
|
||||||
|
| e70ac5e | every task command goes to the helper's fixed folder | platform.rs, powertask.rs, engine.rs, main.rs + test |
|
||||||
|
|
||||||
For the 0.3.14 cut (6 October 2026, after run 6), on ember-tune:
|
For the 0.3.14 cut (6 October 2026, after run 6), on ember-tune:
|
||||||
|
|
||||||
| Commit | What | Where |
|
| Commit | What | Where |
|
||||||
|
|
|
||||||
|
|
@ -275,3 +275,35 @@ the quit ask, paused, syncing, the clock, the update card, the job strip, the lo
|
||||||
| A Windows and a PC screenshot of the row with a real NVIDIA card (the telemetry cells, the cap slider, Retry) | the 0.3.14 cut, first thing to look at |
|
| A Windows and a PC screenshot of the row with a real NVIDIA card (the telemetry cells, the cap slider, Retry) | the 0.3.14 cut, first thing to look at |
|
||||||
| The row's state word while tuning carries the meta after it ("tuning: step 7 of 9 · measuring 441 W · 32 GB · ...") and is cut on a narrow row | a later pass: drop the meta while tuning |
|
| The row's state word while tuning carries the meta after it ("tuning: step 7 of 9 · measuring 441 W · 32 GB · ...") and is cut on a narrow row | a later pass: drop the meta while tuning |
|
||||||
| The merge with `ember-tune`: `app.js` takes this branch's file (it already carries ember-tune's UI words and test); the engine files merge by themselves | the 0.3.14 shipper |
|
| The merge with `ember-tune`: `app.js` takes this branch's file (it already carries ember-tune's UI words and test); the engine files merge by themselves | the 0.3.14 shipper |
|
||||||
|
|
||||||
|
## 14. Polish round (6 October 2026, after the 0.3.14 cut)
|
||||||
|
|
||||||
|
the project lead, on the live Mac app: "I do think this could look better", at the job strip reading "Job: 0.3.14 is published:
|
||||||
|
re-read the manifest and install now (...) done after 0 min. Report uploaded." Branch rebased on master 630b537.
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
|---|---|
|
||||||
|
| No raw job, manifest or relay text on a user surface. The strip reads "A job from the team ran: update check, 0 min." / "A job from the team is running: shard benchmark, 6 min. Proving shard 3 of 8." / "A job from the team failed: build, after 32 min." / "Updated to 0.3.14 at 18:52."; the technical line (the job's title, summary, RESULT line, exit code) sits behind a chevron; the × stays. `Notices.jobWord` maps a kind or an id prefix to a user's word (update check, restart, log collection, shard benchmark, build, tuning check, a script). | app.js Notices |
|
||||||
|
| The Activity feed speaks in the app's voice: `View.plainEvent` maps every engine event string (`src/engine.rs` et al., 80 patterns) to a sentence ("Block found by Apple M5 Max", "Rule settings read from the team", "The node stopped (code 101); restarting", "A job from the team ran: update check, 0 min"); the engine's own line is the row's tooltip; the kind word becomes a coloured dot. Unknown lines keep their words with a JSON tail cut. | app.js View, renderMine |
|
||||||
|
| The header: title, grey subtitle and the pill share one baseline; the pill is a sentence with a coloured dot: "Mining · 511 MH/s" (molten), "Tuning" (molten), "Paused", "Syncing the node", "Starting the worker", "Not mining" (grey), "Node restarting", "Node failed", "Engine not answering" (ember), "Stopping", "Detecting cards", "Setting up". `View.pill`. | app.js, app.css |
|
||||||
|
| Three Mine layouts were offered behind `?variant=a|b|c` (quieter, denser, hero number); the project lead picked C, then ruled the page stays as shipped (section 15). The switch is gone. | app.css |
|
||||||
|
| 390 px: the strip's text, chevron and × on one line. Light and dark checked. | app.css |
|
||||||
|
|
||||||
|
Screenshots: `p04`/`p05` the plain job strip closed and open, `p06` the updated strip,
|
||||||
|
`p07` the header paused, `p08` the activity feed, `p09`/`p12` light, `p10`/`p11` 390 px. Tests: 36 pass.
|
||||||
|
|
||||||
|
## 15. the project lead's ruling: the banner only (6 October 2026, evening)
|
||||||
|
|
||||||
|
the project lead: "lets not actually change the miner page just sort the banner". Variant C and the Earnings reshaping are
|
||||||
|
reverted; the Mine and Earnings screens are exactly as shipped in 0.3.14. The three variant screenshots are gone with
|
||||||
|
them. What stays from the polish round:
|
||||||
|
|
||||||
|
| Kept | Where |
|
||||||
|
|---|---|
|
||||||
|
| The strip's look: the row surface (one step lighter than the page), a 1 px ember hairline along the top, ash text, ember only on the state dot and the chevron; the update strip and the job strip share the one `.notice`; the asks and the clock card carry the same hairline. No filled brown or mustard field anywhere (the rail's active item, the pill, the ghost-on button, the option, the stop ring, the log hit and `mark` lost their tints; only `::selection` keeps a colour). | app.css |
|
||||||
|
| The strip's words: "A job from the team ran: update check, 0 min." / "Updated to 0.3.14 at 18:52.", the technical line behind the chevron, the × kept | app.js Notices |
|
||||||
|
| The Activity feed in the app's voice (`View.plainEvent`, the engine line as the tooltip, a kind dot) | app.js View, renderMine |
|
||||||
|
| The header: title, subtitle and pill on one baseline; the pill a sentence with a coloured dot (`View.pill`) | app.js, app.css |
|
||||||
|
|
||||||
|
Screenshots: `r01-mine-strip-dark.png`, `r02-mine-strip-light.png` (Mine as shipped, the job strip showing),
|
||||||
|
`r03-earnings-dark.png`. Tests: 36 pass.
|
||||||
|
|
|
||||||
BIN
docs/plans/miner-ui-3/p04-strip-job-done-plain.png
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
docs/plans/miner-ui-3/p05-strip-job-done-open.png
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
docs/plans/miner-ui-3/p06-strip-updated.png
Normal file
|
After Width: | Height: | Size: 226 KiB |
BIN
docs/plans/miner-ui-3/p07-header-paused.png
Normal file
|
After Width: | Height: | Size: 215 KiB |
BIN
docs/plans/miner-ui-3/p08-activity-plain.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
docs/plans/miner-ui-3/p09-light-mine.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
docs/plans/miner-ui-3/p10-phone-mine.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
docs/plans/miner-ui-3/p11-phone-earnings.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
docs/plans/miner-ui-3/p12-light-earnings.png
Normal file
|
After Width: | Height: | Size: 187 KiB |
BIN
docs/plans/miner-ui-3/r01-mine-strip-dark.png
Normal file
|
After Width: | Height: | Size: 282 KiB |
BIN
docs/plans/miner-ui-3/r02-mine-strip-light.png
Normal file
|
After Width: | Height: | Size: 284 KiB |
BIN
docs/plans/miner-ui-3/r03-earnings-dark.png
Normal file
|
After Width: | Height: | Size: 197 KiB |
190
docs/plans/miner-ui-4-copy.md
Normal file
|
|
@ -0,0 +1,190 @@
|
||||||
|
# Igneum Miner UI 4: the copy sweep (6 October 2026, 20:0x UK)
|
||||||
|
|
||||||
|
the project lead, on the Cards screenshot: "'As the fleet' might need some work, check all UI copy for simplicity." Every string in
|
||||||
|
`index.html` and `app.js` was read against the rules: no word a home miner would not use (fleet, lane, identities,
|
||||||
|
prior, hill climb, sweep, DAA, epoch, vote key, digest, manifest, shard, segment, aggregator, verifier, worker, engine
|
||||||
|
become plain words or carry one grey explanation), every state line one short sentence with the number first, labels
|
||||||
|
are nouns and buttons are verbs, no colon where a sentence will do, no two-beat lines, one word for one thing (tune,
|
||||||
|
card). Main's correction (20:0x UK): the chain's own objects keep the words of the site, the litepaper and the ledger,
|
||||||
|
with a grey explanation where they first appear: shard (not piece), segment (not run of blocks), checkpoint and
|
||||||
|
"locked checkpoint" (not lock point), aggregator (not combiner), verifying (not checking proofs); "network blocks (the
|
||||||
|
DAA score)" stays. The rows below that renamed those words are superseded by this correction. the project lead's rulings folded in: the per-card inherit option is "Default · <shared goal>" in grey, updating when the
|
||||||
|
shared goal changes; "Default" wherever a per-card setting follows the shared one (the cap's "Back to Default"); the
|
||||||
|
Cards strip is titled "Ember Tune"; every goal option carries its meaning as a tooltip: Efficiency "most hashes per
|
||||||
|
watt, a little rate traded", Balanced "a little rate for most of the saving", Maximum "most hashes, watts second",
|
||||||
|
Default "the goal set for all cards". 150 strings changed.
|
||||||
|
|
||||||
|
## The ten most visible
|
||||||
|
|
||||||
|
| Where | Old | New |
|
||||||
|
|---|---|---|
|
||||||
|
| Cards, the per-card goal | As the fleet | Default · Balanced (grey, follows the shared goal) |
|
||||||
|
| Cards, the strip | (no title) | Ember Tune, with each goal's meaning on hover |
|
||||||
|
| Cards, the Ember line from a prior | Tuned 2 min ago from the fleet prior | Tuned 2 min ago from what other miners found |
|
||||||
|
| Cards, the details label | Identities | Miners on this card |
|
||||||
|
| Cards, the cap lines | cap applied: 460 W / cap not applied: Windows needs the administrator prompt / Unpin | Cap in force: 460 W / Cap not set yet. Windows needs the administrator prompt. / Back to Default |
|
||||||
|
| Cards, the row's state word | held for a remote job / stopped after repeated faults | paused for a job from the team / stopped after repeated errors |
|
||||||
|
| Overview, the big button | switch a GPU on below first / no GPU this app can drive / worker starting | switch a card on in Cards first / no card this app can use / card starting |
|
||||||
|
| Prove | off: relay only / Every block is turned into a short proof. The chain hands pieces to your cards | off, passing proofs along / Every block is turned into a short proof, in pieces called shards. Your cards prove shards and earn IGN for each one (shard, segment and aggregator keep their names, each explained once) |
|
||||||
|
| Node details | DAA score / rules digest / next rule switch / last lock | network blocks (the DAA score) / rules fingerprint / next rule change / last checkpoint (a point the miners agree can never be undone) |
|
||||||
|
| Settings | Vote on finality checkpoints / Trust proof records without verifying them / Hill climb | Vote on checkpoints / Trust proofs without checking them / Fine tuning |
|
||||||
|
|
||||||
|
Every toast and event sentence lost its semicolon or colon joint for a full stop ("Mining stops. The node keeps
|
||||||
|
running"). "Worker" (the engine's word for a card's mining process) became "card" wherever a user reads it; "engine"
|
||||||
|
became "app"; "fleet prior" became "what other miners found". Shard, segment, checkpoint and aggregator stay (the
|
||||||
|
chain's own words), each with a grey explanation where it first appears. The engine's own log strings are untouched: `View.plainEvent` maps them.
|
||||||
|
|
||||||
|
## Every string changed
|
||||||
|
|
||||||
|
| File | Where | Old | New |
|
||||||
|
|---|---|---|---|
|
||||||
|
| index.html | The chain scene canvas label | The last two minutes of blocks: this machine's lane and the others, the selected chain as a lit path, checkpoints as bands | The last two minutes of blocks: your row and the other miners, the main chain as a lit path, locks as bands |
|
||||||
|
| index.html | Node details: DAA score row | <span class="k">DAA score</span><span class="v mono" id="n-daa">0</span><span class="m">blocks the whole network has made</span> | <span class="k">network blocks</span><span class="v mono" id="n-daa">0</span><span class="m">blocks the whole network has made (the DAA score)</span> |
|
||||||
|
| index.html | Node details: votes row | this machine signs a checkpoint every 30 s | this machine signs a lock point every 30 s |
|
||||||
|
| index.html | Node details: digest label | <div class="k">rules digest</div> | <div class="k">rules fingerprint</div> |
|
||||||
|
| index.html | Node details: tips row | open ends of the block DAG right now | open ends of the chain right now |
|
||||||
|
| index.html | Node details: blue score row | blocks on the agreed main chain</span> | blocks on the agreed main chain (the blue score)</span> |
|
||||||
|
| index.html | Node details: next switch label | <div class="k">next rule switch</div> | <div class="k">next rule change</div> |
|
||||||
|
| index.html | Prove: segments row | whole segments this machine proved | whole runs of blocks this machine proved |
|
||||||
|
| index.html | Prove: shard program id label | <div class="k">shard program id</div> | <div class="k">proof program id</div> |
|
||||||
|
| index.html | Prove: aggregator id label | <div class="k">aggregator id</div> | <div class="k">combiner program id</div> |
|
||||||
|
| index.html | Prove: lead sentence | Every block is turned into a short proof. The chain hands pieces to your cards; each piece proven pays IGN. | Every block is turned into a short proof. Your cards prove pieces of it and earn IGN for each one. |
|
||||||
|
| index.html | Settings: Ember Tune switch | Tunes every card for hashes per watt: once after install, then every 7 days, and after a driver or program change. | Tunes every card for the most hashes per watt. Once after install, then every 7 days, and after a driver or program change. |
|
||||||
|
| index.html | Settings: Hill climb switch | <b>Hill climb</b> <span class="dim">Searches around the best point instead of walking the ladders.</span> | <b>Fine tuning</b> <span class="dim">Searches around the best point instead of stepping down a ladder.</span> |
|
||||||
|
| index.html | Settings: vote switch | <b>Vote on finality checkpoints</b> <span class="dim">Signs a checkpoint every 30 s. Votes lock the chain; leave it on.</span> | <b>Vote on lock points</b> <span class="dim">Signs a lock point every 30 s. Votes are what lock the chain. Leave it on.</span> |
|
||||||
|
| index.html | Settings: trust switch | <b>Trust proof records without verifying them</b> <span class="dim">Devnet only. Without a verifier the node includes records it never checked. Changing this restarts the | <b>Trust proofs without checking them</b> <span class="dim">Devnet only. The node includes proofs it never checked. Changing this restarts the node.</span> |
|
||||||
|
| index.html | Settings: remote jobs switch | Signed jobs (a benchmark, a script, logs to collect) run here once and report back. | Signed jobs from the team (a benchmark, a script, logs to collect) run here once and report back. |
|
||||||
|
| index.html | Settings: Power control | Off, NVIDIA cards are measured only. | Off, NVIDIA cards are only measured. |
|
||||||
|
| index.html | Earnings: dev fee switch | Dev fee: 1 block in 100 pays the miner software's author | Dev fee: 1 block in 100 pays the people who make this app |
|
||||||
|
| index.html | Setup: address lead | Paste an Ethereum-style address you control: 0x and 40 characters. | Paste an address you control. It starts with 0x and has 40 characters after it. |
|
||||||
|
| index.html | Setup: GPU note | An integrated GPU is off by default: it is slow and shares the machine's memory. | A built-in GPU starts off. It is slow and shares the machine's memory. |
|
||||||
|
| index.html | Settings: price | pence per kWh. Every £ figure uses it. | pence per kWh. Every £ figure uses this. |
|
||||||
|
| index.html | Settings: name help | A label for you only. | A name for you only. |
|
||||||
|
| index.html | Prove: setup note | About 20 minutes, once. | Takes about 20 minutes, once. |
|
||||||
|
| index.html | Cards: the strip title | <div class="card tune-strip" id="tune-card"> <div class="ts-row"> | <div class="card tune-strip" id="tune-card"> <div class="card-head"><h3>Ember Tune</h3><span class="eyebrow">tunes every card for hashes per watt</span></di |
|
||||||
|
| index.html | Cards: the shared goal options with their meanings | <button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false">Efficiency</button> <button class="seg-b" data-goal="balanced" role | <button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false" title="most hashes per watt, a little rate traded">Efficiency</button> |
|
||||||
|
| index.html | Settings: the shared goal options with their meanings | <button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false">Efficiency</button> <button class="seg-b" data-goal="balanced" role | <button class="seg-b" data-goal="efficiency" role="radio" aria-checked="false" title="most hashes per watt, a little rate traded">Efficiency</button> |
|
||||||
|
| app.js | Cards: the per-card goal options | [['', 'As the fleet'], ['efficiency', 'Efficiency'], ['balanced', 'Balanced'], ['rate', 'Maximum']].map(function (o) { return '<button class="seg-b' + (g === o[0] ? ' on' | [['', 'Default', 'the goal set for all cards'], ['efficiency', 'Efficiency', 'most hashes per watt, a little rate traded'], ['balanced', 'Balanced', 'a little rate for mo |
|
||||||
|
| app.js | Cards: the fleet saving line | ' over ' + n + ' tuned card' | ' across ' + n + ' tuned card' |
|
||||||
|
| app.js | Cards: the Ember line from a prior | ' from the fleet prior' | ' from what other miners found' |
|
||||||
|
| app.js | Cards: the Ember line, measured sub | 'Apple silicon sets its own clocks' | 'Apple silicon sets its own power' |
|
||||||
|
| app.js | Cards: the tune line, prior | ' from the fleet prior, confirmed' | ' from what other miners found, confirmed' |
|
||||||
|
| app.js | Cards: the schedule, off | 'Ember Tune is off. Tune and Tune all still work by hand.' | 'Ember Tune is off. Tune and Tune all still work when you press them.' |
|
||||||
|
| app.js | Cards: the floor note | ' (the ladder’s floor)' | ' (the lowest clock Ember will set)' |
|
||||||
|
| app.js | Cards: identities label | <span class="k">Identities</span><p class="help"> | <span class="k">Miners on this card</span><p class="help"> |
|
||||||
|
| app.js | Cards: identities toast | 'Identities set; that card’s worker restarts' | 'Set. The card restarts' |
|
||||||
|
| app.js | Cards: cap help, Ember may move it | 'Ember Tune may move it.' | 'Default: Ember may move it.' |
|
||||||
|
| app.js | Cards: cap help, pinned | 'Set by hand, so the tune leaves it. <button class="btn tiny ghost" data-pin="0">Unpin</button>' | 'Set by hand, so Ember leaves it. <button class="btn tiny ghost" data-pin="0">Back to Default</button>' |
|
||||||
|
| app.js | Cards: cap applied | '<div class="line ok">cap applied: <b>' + want + ' W</b>' | '<div class="line ok">Cap in force: <b>' + want + ' W</b>' |
|
||||||
|
| app.js | Cards: cap not applied | '<div class="line hot">cap not applied: Windows needs the administrator prompt <button class="btn tiny" data-power-retry="1">Retry</button></div>' | '<div class="line hot">Cap not set yet. Windows needs the administrator prompt. <button class="btn tiny" data-power-retry="1">Retry</button></div>' |
|
||||||
|
| app.js | Cards: chosen by the tune pin | ' <span class="pin">chosen by the tune</span>' | ' <span class="pin">Ember’s choice</span>' |
|
||||||
|
| app.js | Cards: Apple cap help | '<p class="help">Apple silicon manages its own power; there is no cap to set.</p>' | '<p class="help">Apple silicon manages its own power. There is no cap to set.</p>' |
|
||||||
|
| app.js | Cards: facts line worker | cd.worker ? 'worker ' + cd.worker : '' | cd.worker ? 'runs on ' + cd.worker : '' |
|
||||||
|
| app.js | Row word: held | 'held for a remote job' | 'paused for a job from the team' |
|
||||||
|
| app.js | Row word: faulted | 'stopped after repeated faults' | 'stopped after repeated errors' |
|
||||||
|
| app.js | Big button: held | 'a remote job holds the cards; mining restarts when it ends' | 'a job from the team is using the cards. Mining restarts when it ends' |
|
||||||
|
| app.js | Hero sub: no GPU worker | m.state === 'idle' ? 'no GPU worker' | m.state === 'idle' ? 'no card mining' |
|
||||||
|
| app.js | Hero sub: worker starting | (n.synced ? 'worker starting' : 'waiting for the node') | (n.synced ? 'card starting' : 'waiting for the node') |
|
||||||
|
| app.js | Big button: worker starting | sub: n && n.synced ? 'worker starting' : 'waiting for the node to sync' | sub: n && n.synced ? 'card starting' : 'waiting for the node to sync' |
|
||||||
|
| app.js | Big button: switch a GPU on | 'switch a GPU on below first' | 'switch a card on in Cards first' |
|
||||||
|
| app.js | Big button: no GPU | 'no GPU this app can drive' | 'no card this app can use' |
|
||||||
|
| app.js | Strip fallback: mining | 'mining: a block this machine finds appears here' | 'Mining. A block this machine finds appears here' |
|
||||||
|
| app.js | Strip fallback: paused | 'paused: no blocks while mining is stopped' | 'Paused. No blocks while mining is stopped' |
|
||||||
|
| app.js | Strip fallback: worker | state.node.synced ? 'waiting for the worker' : 'waiting for the node to sync' | state.node.synced ? 'waiting for the card' : 'waiting for the node to sync' |
|
||||||
|
| app.js | Chain scene: the note | 'The full chain view needs the next engine update; this is the last 10 minutes of your own blocks.' | 'The full chain view needs the next update. This is the last 10 minutes of your own blocks.' |
|
||||||
|
| app.js | Chain scene: the state word | st === 'failed' ? 'your blocks' | st === 'failed' ? 'your blocks only' |
|
||||||
|
| app.js | Node: next program sub | 'until the next hourly mining program' | 'until the next hourly program' |
|
||||||
|
| app.js | Node: next program unknown | 'known once the node runs' | 'known once the node is running' |
|
||||||
|
| app.js | Node: finality note | 'a point the miners agreed can never be undone; locks at 2/3 of the 30-day weight' | 'a point the miners agreed can never be undone. It locks at two thirds of the last 30 days of mining' |
|
||||||
|
| app.js | Node: finality none | 'locks appear once the miner votes on checkpoints' | 'locks appear once this machine votes' |
|
||||||
|
| app.js | Node: switch line | 'A switch is a planned rule change. The node applies it by itself when the chain reaches that height.' | 'A rule change is planned ahead. The node applies it by itself when the chain reaches that height.' |
|
||||||
|
| app.js | Node: switch line, all behind | 'Every planned switch is behind this node. ' | 'Every planned rule change is behind this node. ' |
|
||||||
|
| app.js | Node: switch line, at DAA | sw.name + ' at DAA ' + withCommas(sw.daa) + ': ' + blocks | sw.name + ' at block ' + withCommas(sw.daa) + ': ' + blocks |
|
||||||
|
| app.js | Node: manifest switch line | 'The update manifest announces a switch at DAA ' + withCommas(n.consensus_switch_daa) | 'A rule change is announced at block ' + withCommas(n.consensus_switch_daa) |
|
||||||
|
| app.js | Node: digest not printed | 'not printed yet' | 'not read yet' |
|
||||||
|
| app.js | Prove: the words off | 'Off. Switch it on and this machine proves the pieces the chain assigns to its keys.' | 'Off. Switch it on and this machine proves the pieces it is given.' |
|
||||||
|
| app.js | Prove: proving sub | sub: pv.current \|\| 'a shard' | sub: pv.current \|\| 'a piece' |
|
||||||
|
| app.js | Prove: idle note | 'On. Nothing is assigned to your keys right now; the next shard arrives within a minute.' | 'On. Nothing to prove right now. The next piece arrives within a minute.' |
|
||||||
|
| app.js | Prove: proving note | 'A proof takes minutes on a CPU and seconds on a CUDA card.' | 'A proof takes minutes on a CPU and seconds on an NVIDIA card.' |
|
||||||
|
| app.js | Prove: tier, cannot prove yet | ': cannot prove yet (the prover is CUDA only).' | ': cannot prove yet (proving needs an NVIDIA card).' |
|
||||||
|
| app.js | Prove: tier, proves while it mines | ': proves while it mines (' + gb + ' GB; a full shard needs ' + SHARD_GB + ' GB).' | ': proves while it mines (' + gb + ' GB; a full piece needs ' + SHARD_GB + ' GB).' |
|
||||||
|
| app.js | Prove: tier, cannot prove a full shard | ': cannot prove a full shard (' + gb + ' GB; a full shard needs ' + SHARD_GB + ' GB).' | ': cannot prove a full piece (' + gb + ' GB; a full piece needs ' + SHARD_GB + ' GB).' |
|
||||||
|
| app.js | Prove: toast on | 'Proving on; the first piece arrives within a minute' | 'Proving on. The first piece arrives within a minute' |
|
||||||
|
| app.js | Prove: verifier words | { command: 'verifying', trust: 'trusting (devnet only)', off: 'off: relay only', unknown: 'not read yet' } | { command: 'checking proofs', trust: 'trusting without checking (devnet only)', off: 'off, passing proofs along', unknown: 'not read yet' } |
|
||||||
|
| app.js | Prove: segments line | 'whole segments this machine proved' | 'whole runs of blocks this machine proved' |
|
||||||
|
| app.js | Prove: segments held | ', ' + pv.segments_held + ' held for the chain rule' | ', ' + pv.segments_held + ' waiting for the chain' |
|
||||||
|
| app.js | Prove: segments last | (pv.segment_last_s ? ', the last in ' | (pv.segment_last_s ? ', the last took ' |
|
||||||
|
| app.js | Earnings: dev fee off | 'Dev fee off: every block pays your address' | 'Dev fee off. Every block pays your address' |
|
||||||
|
| app.js | Earnings: dev fee on | 'Dev fee: 1 block in 100 pays the miner software’s author' | 'Dev fee: 1 block in 100 pays the people who make this app' |
|
||||||
|
| app.js | Earnings: dev fee line | 'The protocol takes nothing; this is the miner software’s fee, the same way every GPU miner takes one' | 'The network itself takes nothing. This is the app’s fee, the same way every mining app takes one' |
|
||||||
|
| app.js | Earnings: dev fee line off | 'The protocol takes nothing either.' | 'The network itself takes nothing either.' |
|
||||||
|
| app.js | Earnings: key help | 'The key for this address was made on this machine and is stored in the app folder, readable by your user only. Keep a copy somewhere safe.' | 'The key for this address was made on this machine and is stored in the app folder. Only your user can read it. Keep a copy somewhere safe.' |
|
||||||
|
| app.js | Settings: jobs note off | 'Off: nothing runs here until the switch above allows remote jobs.' | 'Off. Nothing runs here until the switch above allows jobs from the team.' |
|
||||||
|
| app.js | Settings: jobs note no address | 'No jobs address in this build.' | 'This build has no jobs address.' |
|
||||||
|
| app.js | Settings: jobs note running | 'Running ' + jobTitle(j) + ' (' + j.id + ').' | 'Running: ' + jobTitle(j) + '.' |
|
||||||
|
| app.js | Settings: update note current | 'Up to date' + (u.checked_at ? ', checked ' + rel(state.now - u.checked_at) : '') + '.' | 'Up to date' + (u.checked_at ? '. Checked ' + rel(state.now - u.checked_at) : '') + '.' |
|
||||||
|
| app.js | Settings: update note never | 'Not checked yet. The engine checks by itself every hour.' | 'Not checked yet. The app checks by itself every hour.' |
|
||||||
|
| app.js | Settings: update note rule change | 'A rule switch comes at height ' + withCommas(u.activation_height) + '.' | 'A rule change comes at block ' + withCommas(u.activation_height) + '.' |
|
||||||
|
| app.js | Settings: price toast | 'A price in pence per kWh, for example 28' | 'Type a price in pence per kWh, for example 28' |
|
||||||
|
| app.js | Settings: Ember toast | 'Ember Tune on: once after install, then every 7 days' | 'Ember Tune on. Once after install, then every 7 days' |
|
||||||
|
| app.js | Settings: Power control on toast | 'Power control on: Windows asks for administrator rights once' | 'Power control on. Windows asks for administrator rights once' |
|
||||||
|
| app.js | Settings: Power control off toast | 'Power control off; nothing asks' | 'Power control off. Nothing asks' |
|
||||||
|
| app.js | Settings: trust on toast | 'Trust mode on (devnet only); the node restarts' | 'Trust mode on (devnet only). The node restarts' |
|
||||||
|
| app.js | Settings: trust off toast | 'Trust mode off; the node restarts' | 'Trust mode off. The node restarts' |
|
||||||
|
| app.js | Settings: applied toasts | 'Applied; the miner restarts' | 'Applied. Mining restarts' |
|
||||||
|
| app.js | Cards: switch on toast | 'Worker starting on that card' | 'Starting that card' |
|
||||||
|
| app.js | Cards: switch off toast | 'Worker stopped on that card' | 'That card is off' |
|
||||||
|
| app.js | Cards: cap toast | 'Power cap set; pinned until you unpin it' | 'Power cap set. It stays until you choose Default again' |
|
||||||
|
| app.js | Cards: sendCards default toast | 'Applied; only the changed worker restarts' | 'Applied. Only that card restarts' |
|
||||||
|
| app.js | Cards: stop toast | 'Tune stopped; the card is back where it was' | 'Tune stopped. The card is back where it was' |
|
||||||
|
| app.js | Cards: pin toast | 'The tune chooses the cap again' | 'Default: Ember chooses the cap again' |
|
||||||
|
| app.js | Cards: retry toast | 'Administrator prompt: allow it to set the cap' | 'Allow the administrator prompt to set the cap' |
|
||||||
|
| app.js | Cards: Tune all toast | 'Tune queued for ' + n + ' cards, one at a time' | 'Tune queued for ' + n + ' cards, one after another' |
|
||||||
|
| app.js | Overview: toggle toast | 'Mining stops; the node keeps running' | 'Mining stops. The node keeps running' |
|
||||||
|
| app.js | Address ask | ' from the next block? The miner restarts.' | ' from the next block? Mining restarts.' |
|
||||||
|
| app.js | Trust ask on | 'Include proof records the node never checked? Devnet only; the node restarts.' | 'Include proofs the node never checked? Devnet only. The node restarts.' |
|
||||||
|
| app.js | Trust ask off | 'Verify proof records again? The node restarts.' | 'Check proofs again? The node restarts.' |
|
||||||
|
| app.js | Pill: engine away | 'Engine not answering' | 'App not answering' |
|
||||||
|
| app.js | Node line: engine away | 'the engine is not answering; the window reconnects by itself' | 'the app is not answering. The window reconnects by itself' |
|
||||||
|
| app.js | Button: engine away | 'the engine is not answering' | 'the app is not answering' |
|
||||||
|
| app.js | Hero: engine away | 'no answer from the engine' | 'no answer from the app' |
|
||||||
|
| app.js | Toast: engine did not answer | 'The engine did not answer' | 'The app did not answer' |
|
||||||
|
| app.js | Settings: goal toast | 'The goal needs the Ember Tune update' | 'The goal needs the next Ember Tune update' |
|
||||||
|
| app.js | Cards: card goal toast | 'A per-card goal needs the Ember Tune update' | 'A goal for one card needs the next Ember Tune update' |
|
||||||
|
| app.js | Cards: card goal set toast | 'This card follows the fleet goal' | 'Default: this card follows the goal set for all cards' |
|
||||||
|
| app.js | Activity: identity events | m[1] + ': ' + m[2] + ' identit' + m[3] + '; its worker restarts' | m[1] + ': ' + m[2] + ' miner' + (m[2] === '1' ? '' : 's') + ' on it. The card restarts' |
|
||||||
|
| app.js | Activity: worker ready | m[1] + ': worker ready' | m[1] + ': ready' |
|
||||||
|
| app.js | Activity: worker fault | m[1] + ': worker fault; restarting it' | m[1] + ': an error. Restarting it' |
|
||||||
|
| app.js | Activity: worker stopped | 'A worker stopped (code ' + m[2] + '); restarting in ' + m[3] + ' s' | 'A card stopped (code ' + m[2] + '). Restarting in ' + m[3] + ' s' |
|
||||||
|
| app.js | Activity: worker built | 'Worker built' | 'Mining program built' |
|
||||||
|
| app.js | Activity: program variant | m[1] + ': program variant chosen, ' + m[3] + ' MH/s' | m[1] + ': fastest program chosen, ' + m[3] + ' MH/s' |
|
||||||
|
| app.js | Activity: segment proven pieces | 'Segment proven: ' + m[3] + ' pieces in ' + m[4] + ' s' | 'Run of blocks proven: ' + m[3] + ' pieces in ' + m[4] + ' s' |
|
||||||
|
| app.js | Activity: segment proven | 'Segment proven in ' + m[3] + ' s' | 'Run of blocks proven in ' + m[3] + ' s' |
|
||||||
|
| app.js | Activity: segment paid | 'Segment paid: ' + m[1] + ' IGN' | 'Run of blocks paid: ' + m[1] + ' IGN' |
|
||||||
|
| app.js | Activity: segment held | 'Segment proven: ' + m[3] + ' pieces in ' + m[4] + ' s; its record waits for the chain rule' | 'Run of blocks proven: ' + m[3] + ' pieces in ' + m[4] + ' s. Its record waits for the chain' |
|
||||||
|
| app.js | Activity: segment accepted | 'Segment record accepted' | 'Run of blocks accepted' |
|
||||||
|
| app.js | Activity: node external | 'The team asked for a node restart; this node is not the app’s, so nothing was done' | 'The team asked for a node restart. This node is not the app’s, so nothing was done' |
|
||||||
|
| app.js | Activity: no peer | 'No other node found after 90 s; still looking' | 'No other node found after 90 s. Still looking' |
|
||||||
|
| app.js | Activity: node stopped restarting | 'The node stopped (code ' + m[1] + '); restarting' | 'The node stopped (code ' + m[1] + '). Restarting' |
|
||||||
|
| app.js | Activity: no reading | 'No reading from the node for ' + m[1] + ' s; restarting it' | 'No reading from the node for ' + m[1] + ' s. Restarting it' |
|
||||||
|
| app.js | Activity: mining paused | 'Mining paused; the node keeps running' | 'Mining paused. The node keeps running' |
|
||||||
|
| app.js | Activity: settings changed | 'Settings changed (' + m[1] + '); mining restarts' | 'Settings changed (' + m[1] + '). Mining restarts' |
|
||||||
|
| app.js | Activity: tune best point | m[1] + ': best point ' + m[2] + ' (' + m[3] + '); your cap stays pinned' | m[1] + ': best point ' + m[2] + ' (' + m[3] + '). Your cap stays pinned' |
|
||||||
|
| app.js | Activity: next program compiles | m[1] + ': the next program compiles at the hour boundary instead' | m[1] + ': the next program is built at the top of the hour instead' |
|
||||||
|
| app.js | Activity: new program worker | 'New hourly program; the worker restarts on it' | 'New hourly program. The card restarts on it' |
|
||||||
|
| app.js | Activity: pack | 'Rebuilding the program pack' | 'Rebuilding the mining programs' |
|
||||||
|
| app.js | Activity: trust | 'Devnet trust mode: proof records are not verified (Settings)' | 'Devnet trust mode: proofs are not checked (Settings)' |
|
||||||
|
| app.js | Activity: sweep caps | 'A tune is setting the caps now; retry when it is done' | 'A tune is setting the caps now. Retry when it is done' |
|
||||||
|
| app.js | Activity: updates off engine | 'Updates are off for this engine (a measurement run)' | 'Updates are off for this copy (a measurement run)' |
|
||||||
|
| app.js | Activity: power cap not applied | 'Power cap not applied: the administrator prompt was refused (Retry on the card)' | 'Power cap not set. The administrator prompt was refused (Retry on the card)' |
|
||||||
|
| app.js | Activity: node too old | 'This node is too old for the team’s rule settings; an app update carries the newer node' | 'This node is too old for the team’s rule settings. An app update carries the newer node' |
|
||||||
|
| app.js | Row word: unplugged | 'unplugged; its worker stopped. The row goes in five minutes.' | 'unplugged. Its row goes in five minutes.' |
|
||||||
|
| app.js | Details: removed | '<p class="help">The card was unplugged; its worker stopped. The row goes in five minutes.</p>' | '<p class="help">The card was unplugged. Its row goes in five minutes.</p>' |
|
||||||
|
| app.js | Details: problem | 'No worker can drive this card.' | 'This app cannot use this card.' |
|
||||||
|
| app.js | Setup: no cards | 'No graphics card this app can drive was found.' | 'No graphics card this app can use was found.' |
|
||||||
|
| app.js | Overview: no cards | 'No graphics card this app can drive was found. The node runs on its own.' | 'No graphics card this app can use was found. The node runs on its own.' |
|
||||||
|
| app.js | Pages: overview sub | sub: 'the rate, what it costs, the chain as it grows' | sub: 'your rate, what it costs, the chain as it grows' |
|
||||||
|
| app.js | Pages: prove sub | sub: 'turn blocks into proofs, paid in IGN' | sub: 'prove blocks, earn IGN' |
|
||||||
|
| app.js | Pages: earnings sub | sub: 'what it made, where it goes' | sub: 'what you made, where it goes' |
|
||||||
|
| app.js | Goal names for the Default option | var GOALS = { efficiency: 'the most hashes per watt', balanced: 'hashes per watt within 1% of the top rate', rate: 'the top rate, whatever the watts' }; | var GOALS = { efficiency: 'the most hashes per watt', balanced: 'hashes per watt within 1% of the top rate', rate: 'the top rate, whatever the watts' }; var GOAL_NAME |
|
||||||
|
| app.js | Export GOAL_NAMES | GOALS: GOALS, goalWords: goalWords, | GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, |
|
||||||
117
docs/plans/miner-ui-4.md
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
# Igneum Miner UI 4: Overview, Cards, Ember on the row
|
||||||
|
|
||||||
|
6 October 2026, evening. the project lead: "add Cards under its own tab, and have Overview and the cool mining demo on the main
|
||||||
|
page with the revamped C option", then "incorporate Ember into the Cards tab beautifully", then "I would like the new
|
||||||
|
miner UI to go into .15". Branch `miner-ui-4` from the 0.3.15 tip `6ea9497` (untouched), worktree
|
||||||
|
`../igneum-wt-miner-ui-4`. Rust: one route (`GET /live-dag.js`, `server.rs`), nothing else.
|
||||||
|
|
||||||
|
## 1. The rail
|
||||||
|
|
||||||
|
Overview, Cards, Earnings, Prove, Settings; Logs and Quit in the foot; a bottom tab bar under 720 px.
|
||||||
|
|
||||||
|
## 2. Overview
|
||||||
|
|
||||||
|
| Block | What |
|
||||||
|
|---|---|
|
||||||
|
| The hero (the project lead's pick, C) | the fleet rate at 84 px on a graphite-to-obsidian fade, W with MH/W, £ a day at the user's price, blocks (this run, lifetime) beside it; Start/Stop as the full-width bar under it with its state sentence at the right |
|
||||||
|
| The chain, live | the site's `live-dag.js` (a byte copy at `app/igneum-app/ui/live-dag.js`, served by the engine at `/live-dag.js`), mounted on the engine's own `api/live` with `mine: 'you'` so this machine's lane reads "you" and its blocks take a molten ring; the legend words in HTML under the canvas: yours, pending, included, excluded, proven, locked; Pause; a tooltip per block. The engine's `/api/live` is OWED (it maps the local node's RPC into the site's reply shape: `{ok, state, blocks[{hash, ts, blue_score, daa, parents, chain, color, miner, locked, final, shards, proven}], finality.checkpoints}`); until it exists the module reports `failed` and the app's own 10-minute blocks strip draws in its place with the note "The full chain view needs the next engine update". |
|
||||||
|
| The node line | as 0.3.15, Details under it |
|
||||||
|
| Activity | the event feed in the app's voice (the strip canvas moved into the chain card as the fallback) |
|
||||||
|
|
||||||
|
## 3. Cards
|
||||||
|
|
||||||
|
| Block | What |
|
||||||
|
|---|---|
|
||||||
|
| The Tuning strip | the goal control (Efficiency, Balanced, Maximum) with "about £X a day at Y W"; the fleet saving ("Ember saves 84 W, £0.56 a day over 1 tuned card", `View.fleetSaving`); Tune all; the schedule line; Power control only when an NVIDIA card has it off |
|
||||||
|
| The rows | as 0.3.15 (state word with its reason, MH/s, W with MH/W, £ a day, °C, the switch, the chevron) with Ember as the second layer |
|
||||||
|
| The flame mark | the brand mark's two polygons, 16 px, outline when not tuned, filling bottom-up with step k of n while tuning (molten), solid ember when tuned, half grey when measured only |
|
||||||
|
| The Ember line | "Tuned 2 h ago · 2470 MHz at 80% · next check Sunday · saves 84 W, 0.16% of rate"; "Tuning: step 4 of 9 · about 7 min left" with the bar; "Measured, not tuned · Power control is off"; "Not tuned yet · starts after 2 min of steady mining"; the fleet pause and a stop in the same place |
|
||||||
|
| The W cell | before → after when tuned (`tune_before_watts` → the live draw) |
|
||||||
|
| The MH/W cell | a 44 px sparkline of the ladder's MH/W per step, the chosen step as a dot |
|
||||||
|
| Tune / Retune / Stop | the row's button; Retune once tuned |
|
||||||
|
| The chevron | the cap slider, the applied or Retry line, memory and clocks, the card's own goal (As the fleet, Efficiency, Balanced, Maximum: `POST api/tune/goal {key, goal}`, "" clears; a build without it gets a toast), Retune, the Tuned line, the curve (W across, MH/s up, every step a dot, the chosen ringed, a rejected one hollow), identities, the facts |
|
||||||
|
|
||||||
|
Fields read (Ember Tune 5429e82, on the 0.3.16 lane): `tune_before_watts`, `tune_before_mhs` (step 0 of the last full
|
||||||
|
plan; 0 = never; the UI falls back to `tune_curve[0]` only for a full plan on an older build and claims no saving for a
|
||||||
|
confirm or baseline plan), `sweep_watts`, `sweep_mhs`, `sweep_eff`, `tune_curve`, `tune_goal`, `tune_step`,
|
||||||
|
`tune_steps`, `tune_eta_s`, `sweep_state`, `sweep_note`, `tune_floor` (asked for). `View.ember` and `View.beforeOf`
|
||||||
|
carry the rules; `view.test.mjs` covers every state.
|
||||||
|
|
||||||
|
## 4. Screenshots (the mock on port 4318 with the rig state, the 5090 tuned, the 4070 at step 4 of 9)
|
||||||
|
|
||||||
|
`docs/plans/miner-ui-4/u01-overview-dark-1440.png`, `u02-overview-light-1440.png`, `u03-cards-dark-1440.png` (the
|
||||||
|
5090's details open: cap, goal, Retune, the curve), `u04-cards-light-1440.png`, `u05-overview-dark-390.png`,
|
||||||
|
`u06-cards-dark-390.png`, `u07-overview-light-390.png`, `u08-cards-light-390.png`, `u09-overview-mac-dark.png`.
|
||||||
|
The chain scene in the shots is the mock's `/api/live` (four lanes, one block a second, "you" ringed); on a real
|
||||||
|
0.3.15 engine the fallback strip shows until `/api/live` lands.
|
||||||
|
|
||||||
|
## 5. Owed
|
||||||
|
|
||||||
|
| What | Who |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/live` on the engine: the local node's blocks over the window, mapped into the site's shape, this machine's vote keys as the lane "you" | engine |
|
||||||
|
| `card.tune_floor` | Ember (said yes) |
|
||||||
|
| `watts_total`, `pounds_per_day` for the tray tip; `address.balance_wei`; `price_gbp_per_ign` | engine (from miner-ui-3) |
|
||||||
|
| Re-copy `site/live-dag.js` over `app/igneum-app/ui/live-dag.js` when the site agent's audit commit lands, so the two stay identical | the shipper at merge |
|
||||||
|
|
||||||
|
Tests: `node --test app/igneum-app/ui/*.test.mjs`: 37 pass.
|
||||||
|
|
||||||
|
## 6. The second tip (20:0x UK): /api/live on the engine, the copy sweep, the project lead's goal rulings
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/live` on the engine: the observer's `/api/live` (the URL `ota.rs` already polls for the identity count) through curl, cached 2 s per window, this machine's blocks marked as the lane "you" by matching the block's miner id (the first 8 hex of its vote key hash) against every card's `ids`; `state.you_blocks` and `state.source: "observer"` added; `{ok:false, error}` when the observer is unreachable or the build has no observer address, which the UI meets with its own blocks strip. Four unit tests on the shaping from a fixture in the observer's shape (`live::tests`). The local node speaks gRPC and wRPC only and the engine carries no client for either (no TLS, no websocket stack), so a local-node source is OWED (a JSON endpoint on igneumd, or a wRPC client here); the reply shape stays the same when it comes. | `src/live.rs`, `src/server.rs`, `src/main.rs`, `ota::live_api_from` made `pub` |
|
||||||
|
| The copy sweep: 150 strings, every one listed in `docs/plans/miner-ui-4-copy.md` | `ui/index.html`, `ui/app.js`, the tests |
|
||||||
|
| the project lead's rulings: the per-card inherit option is "Default · Balanced" (grey, following the shared goal); "Default" wherever a per-card setting follows the shared one (the cap's "Back to Default"); the Cards strip is titled "Ember Tune"; every goal option carries its meaning as a tooltip (Efficiency "most hashes per watt, a little rate traded", Balanced "a little rate for most of the saving", Maximum "most hashes, watts second", Default "the goal set for all cards") | `ui/index.html`, `ui/app.js` |
|
||||||
|
|
||||||
|
The Rust tests did not run on this Mac (its cargo predates the lock file's version 4); the box runs them at the
|
||||||
|
merge, as the rule says. UI tests: 37 pass. Screenshots `u01`, `u03`, `u04`, `u06` reshot after the sweep.
|
||||||
|
|
||||||
|
## 7. Pinned to the next engine (ember-tune fd03f35, then b55bc1f), 20:5x UK
|
||||||
|
|
||||||
|
Numbering (the shipper, 21:3x UK): 0.3.16 is tonight's corrective cut, the same release as 0.3.15 (this branch's ui/
|
||||||
|
at 872f1c9) under a new number; the engine fields and the pinning below ship as 0.3.17 with miner-ui-4's tip e31acab.
|
||||||
|
|
||||||
|
| Field | Where the UI reads it |
|
||||||
|
|---|---|
|
||||||
|
| `mining.watts_total`, `mining.pounds_per_day` | the Overview hero and Earnings use the engine's figures when present (`View.fleetWatts`, `View.fleetPounds`), else the UI's own sums as before |
|
||||||
|
| `address.balance_wei` (string or null), `address.balance_age_s` (-1 until read), `address.balance_note`, `address.price_gbp_per_ign` (null until a signed manifest carries a price) | Earnings' first row: "12.3456 IGN · in the wallet · read 20 s ago" (`View.balanceWords`); a priced balance shows £ with "at the signed price"; an older engine without the field keeps the row as it was |
|
||||||
|
| `card.tune_floor` | the Ember line's "(the lowest clock Ember will set)" |
|
||||||
|
| `/api/live` reply `source` ("node" when igneumd answers `igneum_getRecentBlocks`, "site" until then; `pending: true` before the first body) | the chain scene's note (`View.liveNote`): "From your node. Your blocks are ringed." or "From Igneum’s observer. Your blocks are ringed."; "you" is matched in the UI from every card's vote-key ids whatever the source names the lane |
|
||||||
|
|
||||||
|
At the 0.3.17 merge `src/live.rs` is ember-tune's (the fuller one, node first, site second); this branch's 0.3.15
|
||||||
|
file is superseded and its `source` word is "site" too, so nothing in the UI changes between the two.
|
||||||
|
|
||||||
|
## 8. N4 (the frozen tip), the capture route, and PC 2's light render (6 October 2026, 23:0x to 23:4x UK)
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
|---|---|
|
||||||
|
| N4, the app half: `sync_decision_v2` (the old rule AND the watch line's `tip_age_s` at or under 120 s AND peers >= 1; the cause word frozen / no peers / syncing / behind on `node.sync_cause`; the state word "behind" or "no peers", never "synced" on a frozen tip); `is_stall_exit` (code 45 or a "STALLED '" tail line; the first restarts the miner, the second since the node started restarts the node and re-dials its peers); `node.tip_age_s`, `node.stall_exits`. The known-failed case is the first test. UI: "Node behind · 1 peer · 133,000 blocks · last block 53 min ago" with the cause line, the pill "Node behind", the big button's "waiting: the node is behind the chain". The node lane's miner half is ca3-v4-0316. | `src/engine.rs`, `src/state.rs`, `ui/app.js`, `view.test.mjs` |
|
||||||
|
| (b) a screenshot from the machine itself: `POST /api/shot {name}` writes `SHOT <path>` on the host protocol (beside URL, STATE, EXIT); the Mac host answers with `WKWebView.takeSnapshot`, the Windows host with WebView2 `CapturePreview` (PNG), to `<data root>/shots/igneum-<name>-<unix>.png` (`live::shot_path`, the name reduced to [a-z0-9-]); `relay/playbooks/app-shot.ps1` asks and waits, a collect job's glob `shots/*.png` brings the file back. Read-only. The Swift parses (`swiftc -parse`); the C++ builds on the GitHub runner at the cut (`BUILD-APP.bat` already links shlwapi.lib). | `src/server.rs`, `src/live.rs`, `app/mac/IgneumMiner.swift`, `app/windows/host.cpp`, `relay/playbooks/app-shot.ps1` |
|
||||||
|
| The light-theme showcase capture: Edge headless on PC 2 wrote nothing in three takes (154.0.4258.53, exit 0, no stderr, in the app's elevated session); PC 2's `api/state` and `api/live` were fetched by `relay/playbooks/app-state-grab.ps1` into the data root and collected, and the ui-mock replayed them (`IGNEUM_MOCK_STATE`, `IGNEUM_MOCK_LIVE`) through the real 0.3.16 UI on the Mac: `docs/plans/miner-ui-4/pc2-0.3.16/pc2-overview-light-1440.png`, `pc2-cards-light-1440.png`, `pc2-overview-dark-1440.png`, captioned "rendered from PC 2's live state at 23:33 UK" (DESKTOP-KMCV30N, RTX 5090 at 103 MH/s and 317 W, node synced, 5 peers). Handed to the site lane. | `tools/ui-mock/server.mjs`, `relay/playbooks/app-state-grab.ps1` |
|
||||||
|
| The fleet watts count mining and tuning cards only (PC 2's idle integrated card drew 48 W and was summed); the Ember off-state line no longer promises a start. | `ui/app.js` |
|
||||||
|
|
||||||
|
Tests: UI 39; the app crate on the box 152 passed (the N4 pair, the shot path, the four live tests among them).
|
||||||
|
|
||||||
|
## 9. The rebased fold (7 October 2026, 23:20 UK; renumbered: tonight's 0.3.17 is a node-only hotfix on the 0.3.16 app tree, and this branch at afc331bd ships as 0.3.18 on release-0.3.18)
|
||||||
|
|
||||||
|
- Rebased onto a9eb58f1 (origin/release-0.3.15). ui/ comes wholly from this branch; Ember's finality-paused hunk (ember-tune 1357d280) is folded in as `View.finalityWords(f)`: while finality is paused the node sub-line and the lock row say "paused" and nothing calls a lock final. Test in view.test.mjs (41 UI tests).
|
||||||
|
- src/extnode.rs reads the node lane's `igneum_getNodeInfo` shape: `params` (every resolved consensus field, compared value by value against the manifest's keys), `network` (must be equal), `powEngine` ("stub" = refused on the port check, and a fault on the app's own node: node state `stub`, pill "Node fault", one error event). 168 box tests.
|
||||||
|
- src/live.rs is Ember's merged module at the merge (nothing of this branch's lives there any more).
|
||||||
|
- External node re-decided (the Mac, 7 October 2026 00:18 UK: the hand node left and the app sat on "node stopped" all night). While the app reads another node, or refused one, it checks the port every 5 s; gone for 60 s (`extnode::TAKEOVER_WAIT_S`) it starts its own node on the freed ports and says so in Activity; a node back inside the wait is kept. api/state node.mode own | external | none with node.mode_reason, shown under the Node details. Test `an_external_node_that_goes_away_hands_the_ports_to_the_app_after_the_wait` (169 box tests).
|
||||||
|
|
||||||
|
## 10. The merge check (7 October 2026, 03:20 UK; 74c12665, on the 0.3.18 list after afc331bd)
|
||||||
|
|
||||||
|
- Design note from the node lane's 30-s propagation reading: a node behind a slow link keeps mining its own chain and the N4 "synced" test cannot see it. New `src/merge.rs` (pure): `not_merging(MergeCheck)` is true when every sink the network reports is more than the merge depth ahead of ours (the node's `params.merge_depth` via igneum_getNodeInfo, else 3,600) and none of our blocks has appeared in the network's view for 120 s, after at least 120 s of mining. Known-failed test first (`a_node_whose_blocks_never_merge_reads_behind_and_holds_the_miner`).
|
||||||
|
- Engine: every 30 s while mining, the observer's /api/live (600-s window, this machine's lanes marked) gives the network's sink and the newest block of ours it shows (`merge::view_of`); the watch line's decision runs after sync_decision_v2; the state reads "behind" with sync_cause "not merging" and the message "behind: your blocks are not merging into the network"; the miner is held through the existing synced gate; one error event with the gap in blocks. Source owed: peers' own sinks (igneum_getNodeInfo over the peer list) when the node exposes them; the observer is the first view.
|
||||||
|
- UI: nodeWords' behind case names the cause. 42 UI tests, 172 box tests. Nothing of this on the Mac tonight.
|
||||||
|
- Decided (main, 7 October 2026, 05:00 UK): `igneum_getPeers` stays on 0.3.19; the observer-based merge check ships in 0.3.18. The Mac check when it takes 0.3.18: the hands moved to the build box at 00:18 UK, so hand node 1 no longer holds 26610; the app, left in external mode, reads "node stopped" and starts its own node only on relaunch (the apply). Check after the apply: own node on 26610/26611, api/state node.mode own, digest_source rpc, synced; the miner stays paused. Read-only; nothing else that night.
|
||||||
|
- Node side landed: 6e4ace3f on build/ca3-v4-0318 (04:53 UK) adds `blockrate: { bps, ghostdagK, mergeDepth, finalityDepth, pruningDepth }` to igneum_getNodeInfo (devnet: bps 1, ghostdagK 18, mergeDepth 3600). The app reads blockrate.mergeDepth (b7d33e8e) and keeps the 3,600 fallback, since whether 6e4ace3f rides in 0.3.18 is the shipper's call. `igneum_getPeers` (lastDeliveredBlueScore) stays on 0.3.19.
|
||||||
|
|
||||||
|
## 11. Workers after an OTA relaunch (7 October 2026, 05:20 UK; 0.3.18)
|
||||||
|
|
||||||
|
- Known-failed case: PC 1 at the 0.3.17 relaunch (04:51:43Z, docs/plans/release-0.3.17.md PC 1 row): the app started its workers while the node was still syncing; the card watchdog clocked the silence anyway (a miner with no template prints no status), restarted each once, then faulted every card "no status line from the miner for 90 s (restarted once already)", pid 0, 0.0 MH/s on; faulted cards were never restarted, including at the synced transition.
|
||||||
|
- Fix (src/watchdog.rs, src/engine.rs): a miner is judged only while the node is synced (the start, no-status and zero-rate clocks hold at now while it is not); a worker silent from its start with the node synced is one restart then a fault at 60 s (`START_S`), 90 s stays for a running one; a fault the node's readiness explains (`faulted_by_node`) is released by `Event::NodeSynced` when the node syncs, the card starts again with an Activity line; one Activity line per card at its first worker start. Test `a_worker_started_while_the_node_syncs_is_not_faulted_and_a_faulted_one_returns_at_sync` (recorded state), 173 box tests.
|
||||||
|
- The pre-apply card set already persists (cards' enabled flags live in settings and the relaunch planned every enabled card); the fault, not the plan, was the failure.
|
||||||
|
- Read-back changed the reading (relaunch-engine-and-workers.txt, 05:40 UK): PC 1's node reported synced=true from the first second; the miners started at +14, +25 and +38 s, subscribed, then every template fetch timed out at 5 s for 180 s while the node ran its finality catch-up (checkpoints 8053 to 8055 locking), so no status line was printed; the watchdog restarted each at 90 s and faulted each at 180 s. Rule added: "template fetch timed out" is the miner's heartbeat (`Event::TemplateTimeout`): the silence clocks start over from the line, the card reads "waiting for the node to answer block templates", one Activity line per episode, and the miner is judged again from its last line (60 s of nothing at all after it is still a restart). The synced-gate half stays. Test extended with the recorded sequence; 173 box tests. The node-side half (the template RPC blocking under the finality replay after a restart) is with the node lane.
|
||||||
|
- Renumbered again (main, 7 October 2026, 09:2x UK): 0.3.18 is an app-only cut on the 0.3.17 tree (the clock sample gated on igneum_getExecStatus, ledger N7, commit 0e6c1248); this tree ships as 0.3.19 (release-0.3.19, node release-0.3.19-node); decimals is 0.3.20. 0e6c1248's update.rs hunk is carried here so the branch is whole; the Mac check comes on the 0.3.19 apply.
|
||||||
BIN
docs/plans/miner-ui-4/live-0.3.16/live-cards-dark-1440.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-cards-dark-390.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-cards-light-1440.png
Normal file
|
After Width: | Height: | Size: 157 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-cards-open-dark.png
Normal file
|
After Width: | Height: | Size: 235 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-earnings-dark-1440.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-earnings-light-1440.png
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-overview-dark-1440.png
Normal file
|
After Width: | Height: | Size: 388 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-overview-dark-390.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-overview-light-1440.png
Normal file
|
After Width: | Height: | Size: 421 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-overview-strip-dark.png
Normal file
|
After Width: | Height: | Size: 368 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-prove-dark-1440.png
Normal file
|
After Width: | Height: | Size: 134 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-prove-light-1440.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-settings-dark-1440.png
Normal file
|
After Width: | Height: | Size: 356 KiB |
BIN
docs/plans/miner-ui-4/live-0.3.16/live-settings-light-1440.png
Normal file
|
After Width: | Height: | Size: 354 KiB |
BIN
docs/plans/miner-ui-4/pc2-0.3.16/pc2-cards-light-1440.png
Normal file
|
After Width: | Height: | Size: 261 KiB |
BIN
docs/plans/miner-ui-4/pc2-0.3.16/pc2-cards-light-390.png
Normal file
|
After Width: | Height: | Size: 144 KiB |
BIN
docs/plans/miner-ui-4/pc2-0.3.16/pc2-overview-dark-1440.png
Normal file
|
After Width: | Height: | Size: 398 KiB |
BIN
docs/plans/miner-ui-4/pc2-0.3.16/pc2-overview-light-1440.png
Normal file
|
After Width: | Height: | Size: 406 KiB |
BIN
docs/plans/miner-ui-4/pc2-0.3.16/pc2-overview-light-390.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
docs/plans/miner-ui-4/u01-overview-dark-1440.png
Normal file
|
After Width: | Height: | Size: 537 KiB |
BIN
docs/plans/miner-ui-4/u02-overview-light-1440.png
Normal file
|
After Width: | Height: | Size: 561 KiB |
BIN
docs/plans/miner-ui-4/u03-cards-dark-1440.png
Normal file
|
After Width: | Height: | Size: 487 KiB |
BIN
docs/plans/miner-ui-4/u04-cards-light-1440.png
Normal file
|
After Width: | Height: | Size: 304 KiB |
BIN
docs/plans/miner-ui-4/u05-overview-dark-390.png
Normal file
|
After Width: | Height: | Size: 201 KiB |
BIN
docs/plans/miner-ui-4/u06-cards-dark-390.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
docs/plans/miner-ui-4/u07-overview-light-390.png
Normal file
|
After Width: | Height: | Size: 208 KiB |
BIN
docs/plans/miner-ui-4/u08-cards-light-390.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
docs/plans/miner-ui-4/u09-overview-mac-dark.png
Normal file
|
After Width: | Height: | Size: 460 KiB |
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".
|
||||||
167
docs/plans/release-0.3.15.md
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
# Igneum Miner 0.3.15: class v4 signalling, generator-4 workers, miner-ui-4
|
||||||
|
|
||||||
|
Cut 6 October 2026, staged by the release engineer under the coordinator; the live cut waits for the Devnet 2 gate's PASS and the project lead's go. Worktree `igneum-wt-ship0315` (branch `release-0.3.15`), node `release-0.3.15-node` 713ef876 in `vendor/igneum-node-0315`.
|
||||||
|
|
||||||
|
## 1. Why
|
||||||
|
|
||||||
|
- The class v4 program (`mx8+sh256x27`, Counter ASIC 3.0) can now be signalled: the node reads `program_class_v4_activation_daa` and `program_class_v4_signal_window_daa`, flips the class at the first epoch whose 86,400-DAA window has 95 percent of blue blocks signalling, or at the floor. Both fields enter the consensus digest only once set, so this binary peers with 0.3.14 on today's file.
|
||||||
|
- Every GPU worker is the generator-4 one, built from this tree: a worker from 0.3.14 refuses a generator-4 pack and stops dead at the flip (the rehearsal of 6 October showed it on 52 boxes).
|
||||||
|
- The miner app's new Overview and Cards (miner-ui-4): the big-number hero, the chain scene, Ember Tune woven into every card row, the chain's own words with one grey explanation each.
|
||||||
|
- Ember Tune tunes through the Power Helper: the helper acts only on commands after its start, heartbeats before any command, acknowledges by its own log line.
|
||||||
|
- The Windows payload carries the Linux prover pair for WSL2 and refuses to build without it; every PC had run a stale host and exporter since 0.3.5.
|
||||||
|
- An update never installs while a remote job is active, urgent or not; an asked install is spent by the check it asked for (PC 1, 17:52:54Z).
|
||||||
|
- The exec layer names a stale exporter by path; the node says at start when the verifier host is older than proving v1 (`igneum_getProvingStatus.verifierStale`); a tip-0 snapshot pair is never resumed from, and a blocked follower asks a peer for its state within five minutes.
|
||||||
|
- The devnet hand nodes run under launchd; the app's default devnet peers include the build box's hand nodes.
|
||||||
|
|
||||||
|
## 2. The order (two publishes, the worker rule)
|
||||||
|
|
||||||
|
1. **Publish 1** (binaries, the live THIRTEEN-field object unchanged, digest b18ed271): the fleet's miners by script, PC 1, PC 2 and the Mac by `update-now`, the hands and the seed last. No window: a 0.3.15 node on the thirteen-field file peers with 0.3.14 (measured: same digest).
|
||||||
|
2. **Publish 2** (the SIXTEEN-field object: the thirteen plus `exec_restart_state_root`, `program_class_v4_activation_daa` = the floor at publish DAA + 14,400 rounded up to the epoch (at least 10,800 ahead), `program_class_v4_signal_window_daa` = 86,400): only when every NODE reports 0.3.15 AND every WORKER is the 0.3.15 one. A 0.3.14 node exits at parse on this file ("unknown field program_class_v4_activation_daa"); a 0.3.14 worker stops at the flip. Miners first, the hands and the seed last. The digest moves once, read from the 0.3.15 node on the exact file at the publish (2c1162e2 at floor 226,800; 1dddfa55 at 219,600; another floor moves it).
|
||||||
|
3. The hands move from the Mac to igneum-build-1 on the "0.3.15 live" line (the build-server agent's move-hand.sh, observer first, one hand down at a time).
|
||||||
|
|
||||||
|
## 3. Digests (0.3.15 Mac binary, no suffix, no peers)
|
||||||
|
|
||||||
|
| Object | Digest |
|
||||||
|
|---|---|
|
||||||
|
| the live thirteen-field file | b18ed271f75dd46406d230f4156c37472127415a4c32c558bac662f6f840e61c (equal to 0.3.14) |
|
||||||
|
| fourteen fields (the exec pin 0xed27bb2d...) | 23e7693634a9b8b3c0643f16c8a01e4cf057985c7e0e0e044f70bc919a3c0fef |
|
||||||
|
| sixteen fields, floor 219,600, window 86,400 | 1dddfa5574d5536109a5ae68dc415b55e954bd11208608440845e19670505871 (the node lane's pinned test value) |
|
||||||
|
| sixteen fields, floor 226,800 (DAA 209,458 at 19:47Z) | 2c1162e2ccf34531bf3af131d21c36e857e3e99e265969e4b094bd7421a1d74e |
|
||||||
|
| the 0.3.14 binary on the sixteen-field file | refused at parse, exits |
|
||||||
|
|
||||||
|
## 4. Builds
|
||||||
|
|
||||||
|
| Piece | Where | Commit | sha256 | Size |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Linux igneumd (fleet, seed) | igneum-build-1, glibc 2.39 | 713ef876 | 06211d55b8cce1c19b31b218f5b61f42389a5ceaf68e0d27781b2e10e33b3f2a | 49,719,776 |
|
||||||
|
| Linux igneumd (HiveOS, glibc 2.36) | Mac zigbuild | 713ef876 | 1e51bfb6401e2d86022eeaddf03750a72d6eb200fa879b7c58fb5c3afbf38a50 | 48,434,344 |
|
||||||
|
| Linux igneum-miner (HiveOS) | Mac zigbuild | 713ef876 | c5b489105d933b01e4dceff8dc31e2db8e9aa53de06dddafc6eb12ee85b8f7a6 | 9,998,576 |
|
||||||
|
| Windows igneumd.exe | igneum-build-1 cross | 713ef876 | d08be1a72082880e91dceb0f34e4337663c9494ccc62eb108d7c3a274801e928 | 51,846,656 |
|
||||||
|
| Windows igneum-miner.exe | igneum-build-1 cross | 713ef876 | e251bd2b3146830ae8c22627b6b37eba3428d1ba64fbcbd938aa0a36c7916fe3 | 11,129,344 |
|
||||||
|
| Mac igneumd | Mac | 713ef876 | 7f0948d95996eb00f8f574dcaabc25b3ad8a407e08eac3b85055fd15abd0139f | 41,819,824 |
|
||||||
|
| igneum-worker-cuda.exe (generator 4) | Mac mingw | 563485b tree | 14b6637e1d1c9e83311f2cf4e8ca76fb5ff8e42336b5e09b2b89165da55ea00e | 1,537,024 |
|
||||||
|
| igneum-worker-opencl.exe (generator 4) | Mac mingw | 563485b tree | 0cbb65f0cadaa560037175d81d829ad7d50fcbdcf8a510d382681468f32f7ce8 | 479,232 |
|
||||||
|
| igneum-gpu-telemetry.exe | Mac mingw | 563485b tree | f7d924a7ba9ccd6f66fd7eee99f04835f52903456c4789f2c599e88fb02e2d37 | 387,584 |
|
||||||
|
| hive igneum-worker-cuda (generator 4) | Mac zig | 563485b tree | 97e036e23f4ace669b68b85980701ffe4e32017cdfbf28603bf992802a8b9828 | 6,756,480 |
|
||||||
|
| hive igneum-worker-opencl (generator 4) | Mac zig | 563485b tree | ef9fbd03e7ce14d1b2b9b85964397b244dc153edee0607d71f26dbc640a4c5e6 | 307,328 |
|
||||||
|
| igneum-prove-host (Linux, cuda, WSL2) | igneum-build-1 | master e1b5bc9 | 71bc2438856bb141f6cad3d18489f708568144fad5a06a002fbadefb9ce256f9 | 73,161,528 |
|
||||||
|
| igneum-prove-export (Linux, WSL2) | igneum-build-1 | master e1b5bc9 | 263bf4cef70af4a13a45b2e79b8dbab373282ea4c571f624d02ddb5791935361 | 3,609,360 |
|
||||||
|
| Igneum-Miner-0.3.15.dmg (miner-ui-4 872f1c9) | Mac | af36aaa | 90bdf8c8de965961e6b365ee8664bce3efe7873f45f7bc27793b692804fe42f5 | 41,961,932 |
|
||||||
|
| igneum-hive-0.3.15.tar.gz | Mac | | 1715e58ea1d4a1ed1b733881de51b8754ca975f0359b3b29ecb49061d02124c7 | 24,713,232 |
|
||||||
|
|
||||||
|
Rebuilt on the node lane's version-gate commit 17c60367 (the signal stamp and read gated on publish 2's object), 20:0xZ: Linux igneumd 0f1c0ddec8e81256... 49,720,288, igneum-miner ebc392fc4be5f873... 9,979,224 (box); Windows igneumd.exe f764d2080b9b12bde6163abf0252fb8cf195d72fbb0e77e180eb6ee8f5d91a35 51,854,336, igneum-miner.exe 4cf260160722787df29e569dcb66f3743f4c493a7a97dab530e0db990bd5599e 11,129,344 (cross); Mac igneumd 7966152f027c65a66f2573f7e05e78c781be44bd8c52884d7ed9af6d4e2c9d22 41,819,904. Digests unchanged (b18ed271 / 23e76936 / 1dddfa55 at 219,600). Suite: kaspa-consensus 97 + the recorded flake alone, igneum-exec 20, igneum-miner 18, kaspa-pow 15, p2p-flows 33. Superseded by the combined commit (version gate + the sync-panic fix) when it lands; the DMG, the hive package and the inputs rebuild on that one.
|
||||||
|
|
||||||
|
**Final node 7961c5f1** (17c60367 the version gate + 7961c5f1 the pruned-node sync fix), built 20:10Z to 20:15Z with master's reproducibility exports: Linux igneumd 6615571ced35f694fa016e9b36439b29d83cd6e7c1df49fc11a9d4bd0236cba6 49,719,840, igneum-miner 3976c1b270761218298105dd256123381894c6ec7b380a43c4a702e486fe7bf4 9,979,992 (box); Windows igneumd.exe 30b14d174eb5615989e2117425c59a8e59d64e89a7298cab2b3fda15c778534e 51,894,272, igneum-miner.exe 16e9991f27e67995b416e0860f732f354f4d8b2982f46a8268bf91512778aaa1 11,130,368 (cross); Mac igneumd c0a29bcadd782c65770c1d3942a85629158b4178605c2ae3c68ca33e39cd18e2 41,803,088, igneum-miner 414720ed...; HiveOS igneum-hive-0.3.15.tar.gz 79aa96fb12d34948d568af6a2785ee6fff090f216e8df318cfc328d71181e886 (glibc 2.36 node from this commit); Igneum-Miner-0.3.15.dmg ab925203c4e0615f4de9c75f284590366f942ed848505e4338524ad48a315436 41,940,411 (miner-ui-4 872f1c9); Igneum-Wallet-0.1.5.dmg bf37a4ec1b682de86032b24ed2f271149e794177e80880f74dffed54d9a8da71 20,132,824. Digests b18ed271 / 23e76936 / 1dddfa55 at 219,600. Suite: kaspa-consensus 99 pass 0 fail, consensus-core 111, igneum-exec 20, igneum-miner 18, kaspa-pow 15, p2p-flows 33. Inputs pushed 20:13:40Z (node_source_commit 7961c5f1); windows.yml run 37525228879 on c25a3ca (after the Windows-path rename).
|
||||||
|
|
||||||
|
**Final node f1ea7a38** (the five fix commits: 791ff22c, 713ef876, 17c60367, 7961c5f1, f1ea7a38 = the header-version rule on the receive side gated like the stamp), rebuilt 20:42Z to 20:46Z: Linux igneumd 7f0bde70cf2e72a3a9479ba6421f2b37162c3dd4391bf413717c99e6f168668d 49,711,008, igneum-miner 3976c1b2... (box); Mac igneumd and igneum-miner in the log; Igneum-Miner-0.3.15.dmg 041099c2a77f3f7ff145c9a88afc0160e6f5e2951bda0ae46882fbc0259370f5 41,936,114; igneum-hive-0.3.15.tar.gz b710ac596c31d811...; Igneum-Wallet-0.1.5.dmg daf259f272934f0c1a8155ef68762c344164ae8a77c8d0621d0fceed0fb163fa 20,122,390. Digests unchanged (b18ed271 / 23e76936 / 1dddfa55 at 219,600). Node lane's box line: kaspa-consensus 99/0, consensus-core 111 + 7. The compat gate with a POISONED peer (node-compat.mjs --poisoned, 20:42:55 to 20:45:42Z): PASS, the new node refused the poisoned blocks 12 times with 0.3.14's exact line and relayed nothing, the old hub's chain holds no 1026 block, 180/180/180/180/180 on every clean node, the new node mined 72 accepted blocks. Windows cross on f1ea7a38: igneumd.exe 181e10fd4b1ad0c259122d45abac018082a054c3836431f6802f0532a8ee1a43 51,894,272 (carries f1ea7a38), igneum-miner.exe 16e9991f... unchanged; inputs pushed 20:59:15Z; windows.yml run 37530680431 on 24132fb. Two earlier dispatches (37529276665, 37529597068) were cancelled: master's tools/cross-remote.sh had its defaults line swallowed by a trailing comment since the slots merge, so a default cross-build died under bash 3.2 ("CARGO_ARGS: unbound variable", then "OUT") and the chain silently kept 7961c5f1's exes while the inputs said f1ea7a38 (fixed on master 36e4ee7 with tools/ci/defaults-line-check.sh; merged here). Rule for me: an artefact's commit string is read before it is pushed anywhere (the inputs step now prints it; the installer check too). The Mac igneumd on f1ea7a38: 85ac04df27b342f5cd225762a940400bb6142401f33b41fc7ea8ea462508ded1 41,819,648. Suite on the box on f1ea7a38: kaspa-consensus 99/0, consensus-core 111 + 7, igneum-exec 20, igneum-miner 18, kaspa-pow 15, p2p-flows 33.
|
||||||
|
|
||||||
|
**Final Windows on f1ea7a38** (windows.yml run 37530680431 on 24132fb, 18 of 18 steps, 21:07Z): Igneum-Miner-Setup-0.3.15.exe 99ec575ccbeef2e0fd1ff5c4ce1646905cb5e88efe31f71eb3058337641b3ec7 62,652,179; igneum-windows-app.zip a379bc446d55b8d659d72e7fd403749cb9d6d751dd9a39a8be2672cbfd1c7ecd 90,511,038 (its igneumd.exe 181e10fd carries f1ea7a38; wsl2/bin/SHA256SUMS names the prover pair 71bc2438 / 263bf4ce). Final manifest 0.3.15: mac 041099c2 + windows 99ec575c, the thirteen-field object, signed and verified locally; the site index Mac/Windows/HiveOS at 0.3.15 (hive b710ac59). Deploy on the retry's PASS.
|
||||||
|
|
||||||
|
Earlier Windows (windows.yml run 37525228879 on c25a3ca, 18 of 18 steps, 20:24:00Z; superseded): Igneum-Miner-Setup-0.3.15.exe 9db842fce524b2a45978f0de6060923ed67144204131724d517b9e1a063695c9 62,663,008; igneum-windows-app.zip 65d463c305d23128896e33c10cca9c6dc6ccac12ba829ed1890d3d4a2acd9240 90,518,322 (the Linux prover pair inside: wsl2/bin/igneum-prove-host 73,161,528 with SHA256SUMS). Manifest 0.3.15: mac ab925203 + windows 9db842fc, the thirteen-field object, signed and verified locally (the deploy on main's line).
|
||||||
|
|
||||||
|
Installer, zip and manifest rows above (`tools/ship-app.mjs 0.3.15 ... --until manifest`).
|
||||||
|
|
||||||
|
## 5. Gate
|
||||||
|
|
||||||
|
| Line | Result |
|
||||||
|
|---|---|
|
||||||
|
| node suite on igneum-build-1 (713ef876, six crates, --no-fail-fast) | kaspa-consensus 97 + the recorded flake `ban_is_decided...` 1/1 alone, consensus-core 111 + 7, igneum-exec 20, kaspa-pow 15, igneum-miner 18, p2p-flows 33 |
|
||||||
|
| app crate tests (release-0.3.15) | 162 + 28 + 8; UI 37 |
|
||||||
|
| worker smoke, Mac (Metal, serve mode, epoch-2 v4 pack 24304f0788ea9408) | 1,024 of 1,024, sha256 7bf77506... equal to igneum-pow's derivation (19:16Z) |
|
||||||
|
| worker smoke, Linux (the hive package's igneum-worker-cuda 97e036e2 on the fleet's p1-4090, RTX 4090) | 1,024 of 1,024, sha256 7bf77506... equal to the Mac's; done g2 in 2.44 s, self-test PASS (19:2xZ; rechecked from this tree) |
|
||||||
|
| worker smoke, Windows (the kit's igneum-worker-cuda.exe 14b6637e on PC 2's RTX 5090, job v4-smoke-1ccfe586) | 1,024 of 1,024, sha256 7bf77506... equal to the Mac's; done g2 in 2.71 s (19:24:25Z) |
|
||||||
|
| Live retry on 7f0bde70, clean form (p2-4090-3 wiped and mining beside the 0.3.14 peers, 21:27Z to 21:37Z) | PASS: 0 version-1026 headers accepted or relayed through a 129,000-block IBD and the window, 0 rejects from any peer, never disconnected, the 0.3.14 hub holding 3 blocks by its key mined in the window; verifierStale null |
|
||||||
|
| Live forced poisoned-peer confirmation on f1ea7a38 (22:18Z to 22:24Z, after publish 1: the 713ef876 miner into the 6615571c relay pool-1, the target p2-4090-3) | PASS on the receive side: 6 relayed 1026 blocks refused with "wrong block version: got 1026 but expected 2, disconnecting from peer 213.173.98.36:18451", none accepted or relayed, the hands and the hub never rejected the target, its mined blocks kept landing in the hub; both boxes restored with wiped datadirs |
|
||||||
|
| Devnet 2: P1 rehearsal | PASS (19:33Z): the flip by signal on 52 nodes, three epochs' ids, zero rejected on 16 nodes, the stale box refused both ways, blocks on v4, the floor crossed with v4 in force |
|
||||||
|
| Devnet 2: 0.3.15 canary (run 4, 19:41Z: p2-3090-1, p2-4090-3, p1-3080, p1-a5000 on 713ef876, the thirteen-field file) | FAIL. The handshake peers (digest b18ed271) but the node writes block version 1026 (the class v4 signal bit) from its first block with no window and no floor set; every 0.3.14 node answers "wrong block version: got 1026 but expected 2" and disconnects (the hub: 45 disconnects since 19:41Z, 146 today); p2-3090-1 fell behind and could not re-sync (peers 0); the siblings lost every block they mined. Rolled back to 0.3.14. |
|
||||||
|
|
||||||
|
## 6. Incidents of the day that shaped this cut
|
||||||
|
|
||||||
|
- 17:52:54Z, PC 1: 0.3.14 installed under a measurement job; cause install_asked from a two-hour-old update-now, which counts as urgent (section 1, the updater rule).
|
||||||
|
- 18:2x UK: the desktop app crashed and both hand nodes (nohup children of agent shells) died for about 70 minutes; now LaunchAgents (`infra/devnet/hands-launchd.sh`).
|
||||||
|
- 18:3xZ: every PC ran a stale igneum-prove-host and igneum-prove-export (the payload never carried the Linux pair); placed by job tonight (zigbuild pair 75166ad8 / 24d34cb9), required in the payload from 0.3.15.
|
||||||
|
- 18:58Z: `restart --what app` jobs to both PCs quit the apps and the relaunch helper did not bring them back (the engine exits under the stale window host); retired until fixed with a watcher. Recovery through the relay agent on PC 2; PC 1's agent was down.
|
||||||
|
- 17:47Z onward: the pre-push hook's site build wrote 0.3.14's download rows into five worktrees; fixed on master (SITE_DOWNLOADS_REFRESH, tools/ci/no-foreign-tree-writes.sh).
|
||||||
|
- 18:5xZ: the P1 rehearsal's flip stopped every GPU miner on the 0.3.14 worker (generator-4 refusal); hence section 2's worker rule.
|
||||||
|
|
||||||
|
## 6a. The evening's two blocks on the ship itself
|
||||||
|
|
||||||
|
- 19:24Z onward: every GitHub-hosted Actions job dies at start ("recent account payments have failed or your spending limit needs to be increased"); the Windows installer and payload zip are made only by windows.yml on windows-latest, so the Windows side waits for the org's Billing & plans. The Mac DMG, the HiveOS package and the Linux binaries do not depend on it.
|
||||||
|
- 19:30Z: tools/ship-app.mjs's commit step pushed the release tree's HEAD to origin/master although the run had --branch release-0.3.15 (the step pushes master by design); master carries the 0.3.15 tree from 272e30a. Row: the tool honours --branch for the push, and the after-rollout merge is thereby already done.
|
||||||
|
- The inputs step compared the state's short fork commit with the manifest's full one (`!==`): two needless re-pushes and a false failure; fixed (a prefix match, the manifest read bypasses the CDN cache).
|
||||||
|
- `--until <step>` added to the ship tool: a cut is staged (built, signed, verified locally) with nothing deployed.
|
||||||
|
|
||||||
|
## 6c. The canary's FAIL and the gate line that was missing
|
||||||
|
|
||||||
|
The digest readings were on a throwaway node with no peers; the node lane's compat harness peered a new and an old node on the thirteen-field file but neither mined; the rehearsal ran the signalling binary on every node. None of the three put a BLOCK from a new node in front of an old node. The line every node cut needs from here: a mining new node beside an old node on the live file, the old node accepting the new node's blocks, plus a fresh join of a clean new node against an old peer. Proposed for CLAUDE.md's Devnet 2 rules (main's call).
|
||||||
|
|
||||||
|
The fix (node lane): the signal stamp and the signal read gated on publish 2's object (both v4 fields set); a template on the thirteen-field file has header version 2, on the sixteen-field file 1026; the digest tests unchanged; the harness with the new node mining. Then the node rebuilds (Linux, Windows cross, Mac), the digests are re-read, the canary runs again. The app tree needs nothing.
|
||||||
|
|
||||||
|
## 6d. The second canary FAIL (20:36Z): the receive side
|
||||||
|
|
||||||
|
On 7961c5f1 (the stamp gated), two boxes with clean datadirs got "wrong block version: got 1026 but expected 2" from both hands within three minutes: a version-1026 block from the poisoned boxes' branch reached them via relay through pool-1 (a 7961c5f1 node poisoned by p1-a5000), the new node ACCEPTED and RELAYED it, the 0.3.14 nodes rejected the relay and disconnected the sender. One poisoned peer poisons every new node it touches. The fix (node lane): the header-version RULE gated like the stamp (window or floor absent: version 2 only, 1026 rejected with 0.3.14's line, no relay; publish 2's object: both accepted); unit tests both ways; the compat harness with a poisoned peer. The retry form adds the poisoned peer relaying into the new node. Fleet: pool-1 and the three retry boxes held on 6615571c for the next cut; the 0.3.14 poisoned boxes (p1-3080, p2-3090-1, p1-a5000) wiped and fresh-joined from the hub.
|
||||||
|
|
||||||
|
Lesson for every node cut: the compat gate needs the receive side as well as the mining side: a peer feeding the new node the headers the rule must refuse, with the old node watching the new node's relays.
|
||||||
|
|
||||||
|
## 6e. Publish 1 as run (21:39Z to 21:47Z, 22:39 to 22:47 UK)
|
||||||
|
|
||||||
|
- 21:40Z deploy: the public folder verified file by file (200 with the local sizes); the live manifest 0.3.15 with mac 041099c2 and windows 99ec575c, the thirteen-field object (13 fields, exec_restart_number 27276), digest b18ed271.
|
||||||
|
- 21:41:33Z update-now to PC 1 (ae432dc7), PC 2 (1ccfe586) and the Mac (d937c69d). PC 2 took it at 21:42:26Z ("updated to Igneum Miner 0.3.15 from 0.3.14", node igneumd 2.1.0-f1ea7a38); the Mac and PC 1 show app 0.3.15 on the console within the minute.
|
||||||
|
- 21:42Z the hands (announced), through hands-launchd.sh on the Mac igneumd 85ac04df: observer pid 60931 and node 1 pid 61387, both igneumd/2.1.0-f1ea7a38, digest b18ed271, each resumed from its own exec snapshot (node 1 at tip 147,215), eth_blockNumber 147,233 on 26790 and 26791 a minute later.
|
||||||
|
- 21:43Z the seed: the box's Linux igneumd 7f0bde70 (glibc 2.39) does not run on the seed (Debian 12, glibc 2.36: "version GLIBC_2.38 not found", 14 restarts in a loop, about three minutes down); re-run at 21:46Z on the zigbuild igneumd ede4a61ac9ab0993c644f82d61077c34fce11d7f8ffc642e8052887b978deeb3 (48,421,224, glibc 2.34 max, carries f1ea7a38): active, exec resumed from its own snapshot at tip 147,247. Rule: the seed and every Debian 12 host take the zigbuild (glibc 2.36) binary, the fleet (Ubuntu 24.04) takes the box's; restart-seed.sh now has to refuse a binary whose GLIBC need exceeds the host's (row).
|
||||||
|
- The fleet's standing boxes: by the fleet agent's script on 7f0bde70 (table owed).
|
||||||
|
|
||||||
|
## 6f. The leak: the Mac and PC 1 took earlier 0.3.15 builds before the publish
|
||||||
|
|
||||||
|
The downloads folder (~/Projects/igneum-dlsite) is shared disk state and every `publish-jobs.sh --deploy` by any agent deploys the whole of it; the dl project deployed every few minutes all evening (other lanes' job publishes). So the manifests and DMGs I wrote there "undeployed" at 19:4xZ (DMG on node 713ef876) and 20:24Z (installer on node 7961c5f1) went live within minutes. The Mac applied 0.3.15 at 20:12:25Z (ota-apply.log; its installed igneumd carries 713ef876, its igneum-bench is the 19:41Z DMG's); PC 1 took 0.3.15 at its 20:35Z slot (installer 9db842fc, node 7961c5f1). Neither took the 21:40Z publish: the updater compares version strings only, and the version scheme is three-part, so a same-version republish cannot reach them. Harm so far none (the Mac's miner is paused; PC 1's node writes version 2; no 1026 header can be created any more). The move: a version bump with the same tree (main's number), update-now to both; or hand installs.
|
||||||
|
|
||||||
|
Rule rows: nothing is staged in the live downloads folder (a scratch copy, or publish-manifest.sh writes to a staging folder, and the live folder changes only in the deploy step); `publish-jobs.sh --deploy` deploys a jobs-only change, never the whole folder; a box's installed build is read back by its node commit string (not the version) after every publish.
|
||||||
|
|
||||||
|
## 6g. 0.3.16, the corrective cut (21:55Z to 22:13Z): every machine on f1ea7a38
|
||||||
|
|
||||||
|
The same tree under a new number (8bf922d; node pin unchanged at f1ea7a38), because the Mac and PC 1 held earlier 0.3.15 builds and the updater compares version strings only. DMG Igneum-Miner-0.3.16.dmg 0a542bad002c252bc7a80c5810781e9ec8ea31173ce2bd597a9e29892c50b6c3 (41,936,050); windows.yml run 37537291393 (18 of 18, 22:03:42Z): Igneum-Miner-Setup-0.3.16.exe a1eaa1dfaa0cd428378fd1ab86245e146c180070861dc4643d62773a92239a0c (62,671,852), igneum-windows-app.zip b47939c1... (90,520,492), the payload's igneumd.exe carrying f1ea7a38 (read before the push). Manifest 0.3.16 with both rows written and deployed in one step at 22:04Z. update-now 22:08Z. Read-back: the Mac applied 22:12:27Z ("0.3.16 is running", installed igneumd carries f1ea7a38, api/state 0.3.16 current, miner paused); PC 1 "updated to 0.3.16 from 0.3.15" 22:12:33Z, node 2.1.0-f1ea7a38; PC 2 the same at 22:11:24Z, 95.2 MH/s. Hands, seed and the fleet's 14 boxes as in 6e; the hub's last 1,000 blocks at 22:10Z hold blocks by 11 of 14 standing keys (the other three in IBD or in the confirmation). HiveOS renamed igneum-hive-0.3.16.tar.gz (the same bytes, b710ac59) at 22:17Z. The jobs-only deploy guard fired once on its first use (the index's "updated" stamp alone) and now ignores that field. The Discord release card for 0.3.16 posted 22:18Z (message 1557154778415505432).
|
||||||
|
|
||||||
|
0.3.17 is the planned tree (the lanes renumbered: node ca3-v4-0316 plus the proving lane's exec-sync-0313 tip past da2d17ec with the override's proving ids as the statement's ids when set, Ember 8f581de, miner-ui-4 past e31acab, proving 38b65e7 / fc08246 with X34, the finality LEAVE item, relay 28c028b, the testing-integration crate fixed). Devnet 2 nodes on 4c6b129d need IGNEUM_PROOF_PROGRAM_IDS=0x2b1a81cb...,0x474678f3... in their environment until then (the fleet has the line).
|
||||||
|
|
||||||
|
## 6h. Wallet 0.1.5 published (22:27Z) and publish 2's floor rule
|
||||||
|
|
||||||
|
Igneum Wallet 0.1.5: DMG daf259f272934f0c1a8155ef68762c344164ae8a77c8d0621d0fceed0fb163fa (20,122,390; node f1ea7a38, the live thirteen-field object bundled; wallet-ui-3 ab8dfdc). No wallet publisher script exists (0.1.4's manifest was written by hand): igneum-wallet-latest.json written in the app manifest's canonical form, signed with igneum-ota-sign and verified, then publish-public.sh --app --wallet --deploy; live at 22:27Z, the site's wallet row 0.1.5. The fresh-joiner gate line could not run on this Mac (the installed 0.1.4 wallet's protocol-12 node holds 26620/26800; it is the hub's "level is 0" source); the installed wallet's own OTA to 0.1.5 is the live check, read back below. Row: a wallet publisher (publish-manifest.sh --product wallet).
|
||||||
|
|
||||||
|
Publish 2's floor: the node flips the class at the floor unconditionally (class_signal.rs: the floor rule when the floor has passed), so the object carries program_class_v4_activation_daa a week past the publish (DAA + 604,800 rounded up to the epoch; 828,000 at 22:24Z), the window 86,400, the exec pin; the digest with that object read 0f9e26fe on f1ea7a38 and is recomputed at the publish. The vote opens with publish 2; the flip comes by the 95 percent signal; the floor is the backstop a week out (the 0.3.17 P2 rule in advance).
|
||||||
|
|
||||||
|
## 6i. Publish 2 as run (22:42Z to 22:57Z, 23:42 to 23:57 UK): the vote is open
|
||||||
|
|
||||||
|
- The object from the live DAA at 22:42:52Z (223,667): the thirteen + exec_restart_state_root 0xed27bb2d... + program_class_v4_activation_daa 831,600 (epoch 231, a week out, the unconditional floor's backstop) + program_class_v4_signal_window_daa 86,400. Digest on f1ea7a38: eada4bda8aa8368c2b2c3d17744bc7a70a0ff0e996dad681884d3ac5de1207eb. Manifest 0.3.16 with the sixteen-field override written and deployed in one step at 22:43Z (live read 22:46Z: 16 fields, floor 831600, window 86400).
|
||||||
|
- 22:46Z update-now to the Mac, PC 1, PC 2; the PCs' nodes restarted on the object ("consensus override changed", "Program class v4 from the override file: active from epoch 231", "Consensus params digest: eada4bda") by 22:53Z. The Mac's app held its node restart "for a safe moment"; a `restart --what node` job at 22:59Z restarted it 23:01Z (synced, 7 peers); its own digest line is not in the app's log (the node's stdout is not logged there): row.
|
||||||
|
- The fleet by the fleet agent 22:47Z to 22:54Z: 14 of 14 standing boxes print eada4bda on f1ea7a38 with the sixteen-field override.json (the thirteen-field file kept as override-13.json), the two program-class lines on every node, miners up (p2-3090-3 and pool-1 finishing fresh IBDs on the object); the five-minute check reads eada4bda as the live digest and posts on any box left behind. Expected during the sweep: every moved box logged 3 to 12 digest-mismatch refusals from the not-yet-moved hands and seed.
|
||||||
|
- 22:56Z the hands (announced): observer pid 13170 and node 1 pid 13188, eada4bda, "active from epoch 231", exec resumed from their own snapshots (node 1 at 149,729), eth_blockNumber 149,742; the seed 22:56:04Z: eada4bda, igneumd/2.1.0-f1ea7a38, 16 fields in /etc/igneum/override-v3.json, active.
|
||||||
|
- The window: 86,400 DAA ending at each epoch's seed block, 9500 bps of blue blocks signalling flips the class at the next epoch; the flip comes by miner signal; the floor 831,600 is the backstop (about 7 days at 1 DAA/s).
|
||||||
|
|
||||||
|
## 6b. Publish 1 as staged (the downloads folder, local until the line)
|
||||||
|
|
||||||
|
- Manifest 0.3.15, Mac row only (Igneum-Miner-0.3.15.dmg 90bdf8c8, 41,961,932), the thirteen-field object, signed; a Windows box reads "no file for this platform" and waits. Site rows: Mac 0.3.15, HiveOS 0.3.15 (1715e58e), Windows carried on 0.3.14 (publish-public.sh keeps a platform's previous file when the manifest lacks it: packaging/ota/aliases.py), wallet 0.1.4.
|
||||||
|
- Sequence (scratchpad r0315/publish1.sh): deploy the folder; update-now to the Mac (d937c69d, miner paused per the project lead); the fleet's standing boxes by the fleet agent's script (06211d55, generator-4 workers); the hands on the Mac binary 7f0948d9 through hands-launchd.sh (announced); the seed on 06211d55; every version and digest line read back. The Windows row joins through `ship-app.mjs --from ci` when GitHub's billing block lifts. The Discord card waits for the full set.
|
||||||
|
- Trigger: the fleet's canary PASS (run 4: three of four boxes met every criterion at 19:49Z; p2-3090-1's re-sync is the open verdict) or main's call at 20:00Z.
|
||||||
|
|
||||||
|
## 7. Owed
|
||||||
|
|
||||||
|
- Publish 2 after every node and every worker reports 0.3.15; the hands' move; the first locked checkpoint after the flip.
|
||||||
|
- Pool-v0 fork commits (48c8163b, b8070476) for 0.3.16 (b8070476 conflicts with the 0.3.15 miner).
|
||||||
|
- Ember 995530b (card.tune_floor) for 0.3.16.
|
||||||
|
- The prover thread drops its cached tool paths when a stale binary is named (0.3.16).
|
||||||
|
- The relaunch helper fixed with a watcher, or `restart --what app` stays retired.
|
||||||
|
- Env-only defaults for the eight scripts that name the shared checkout by absolute path.
|
||||||
|
- The Mac app's own igneumd never runs while hand node 1 holds 26610/26611 on this Mac: the app reads the hand node as its own ("node1" watch lines), api/state's digest field reads empty. A port collision must be loud in the app, and the hands' move to the box frees the ports.
|
||||||
|
- The app's log does not carry its node's stdout, so the node's digest line is unreadable from api/log: the app should log the node's "Consensus params digest" and "Program class" lines (or expose consensus_digest from the node's RPC).
|
||||||
|
- The build box's 188.40.146.49:26611 answers "Network mismatch: igneum-devnet-2" to the 0.3.16 apps' default peer dial; the live-devnet hand must own it before the hands move.
|
||||||
|
- Publish 2's "safe moment" hold: with the miner paused the Mac's engine held the override restart until a restart job; the hold's condition wants reading (an idle engine should restart its node at once).
|
||||||
|
- 0.3.17 node tree (ca3-v4-0316; the planned tree moved one number: 0.3.16 is the corrective cut of 6 October, the same release as 0.3.15 under a new number): 8e2f5cbe (the unwrap class), 0760b844 (the seven-window signalling rule, gate PASSED four cases), b2e21447 (finality cause fields), eec34ac3 (igneum_getRecentBlocks), 325f8b98 (difficulty rule v3 behind difficulty_v3_activation_daa at u64::MAX, digest unchanged until set). Pre-existing breakage to fix in that cut: `cargo check -p kaspa-testing-integration --tests` fails on the fork (the Header initialiser lacks vote_key_hash; the KaspadPayloadOps match is non-exhaustive on the finality ops), broken since the finality fields landed; the box suite then compiles EVERY test crate (`cargo test --no-run --workspace` or the crate list widened) so a test crate cannot rot silently again.
|
||||||
|
- 0.3.17 node tree (was 0.3.16), proving agent: exec-sync-0313 da2d17ec (on 2e5d3d30) = consensus proof verification in-process (sp1-sdk 6.8.1 linked, 169 crates; three override fields in the digest once set: proving_consensus_verify_daa, proving_shard_program_id 0x2b1a81cb..., proving_aggregator_id 0x474678f3...; p2p payload 78 IgneumRequestProofRecords; a node with other embedded keys exits 3 at start); never by default, a digest move when switched, miners first. App side exec-app-0314 c00e608 (the proving-dir cap, forged.mjs harness 8/8, ledger P21 round 4 and its export-disk row labelled X31, which the site lane already used on master (X31 the testnet date, X32, X33): renumber the proving row X34 at the merge and rerun the ledger-text check). The fork's proof fixture is untracked at igneum/exec/testdata/ (the test skips without it): a build row.
|
||||||
|
- Wallet 0.1.5 (Igneum-Wallet-0.1.5.dmg daf259f2, node f1ea7a38, wallet-ui-3 ab8dfdc): the gate and the publish straight after "0.3.15 live" (the wallet in the wild bundles a 5 October igneumd at protocol 12, the hub's 172 "level is 0" rejects); not held.
|
||||||
|
- A standalone igneum-miner does not recover its template subscription after a node restart (fleet, 20:24Z): ledger row, fix = resubscribe on reconnect (0.3.16, the miner's owner); the fleet's restart script restarts the miner with the node until then.
|
||||||
|
- 0.3.17 node tree (the finality lane's branch notes still say 0.3.16; the lane is closed, the number here is the one that counts): the finality lane's LEAVE item (finality-pause-node 766e70ca on the box mirror /srv/igneum-node.git, one commit on 4c6b129d, rebases onto the 0.3.15 node with only params.rs overlapping; docs on origin finality-pause 27f82ec) is a digest move (the devnet digest becomes 63703f5b..., protocol 17) gated by finality_leave_activation_daa (never on devnet until the 95 percent signal); its Devnet 2 gate is tools/finality-attacks/leave.mjs both ways with the new binaries, before it merges.
|
||||||
|
- With the after-rollout merge: the relay security fixes X23 to X28 (28c028b, on fud-close and the ledger-* branches only; the intake key gets 403 on console writes while master lacks them).
|
||||||
|
- DONE 20:3xZ: packaging/ota/publish-manifest.sh refuses an --activation-height at or below the live DAA (the observer's executedTipDaa; IGNEUM_MANIFEST_ALLOW_PAST_HEIGHT=1 for a replay; --self-test-height), the engine half with the Ember agent for 0.3.16.
|
||||||
|
- 0.3.17's app merge (was 0.3.16), as the lanes handed it: app/igneum-app/ui/ whole from miner-ui-4 57bb4f7 (the UI pinned to the engine fields, the Ember off-state copy, N4's app half: sync_decision_v2, is_stall_exit, NodeState.tip_age_s / sync_cause / stall_exits in engine.rs and state.rs, which want the node lane's miner half (exit 45, the STALLED line) in the same cut); src/live.rs, ui/app.js, the /api/live route and packaging/ota/publish-manifest.sh from ember-tune da11c6f (cbd4d51: one monotonic wire sequence space for every helper writer, powertask::wire_seq; without it a 0.3.16 user with Power control on gets the caps and no tune; pinned to the node lane's ca3-v4-0316 b2e21447 finality-pause fields and eec34ac3 igneum_getRecentBlocks, reading the absent-field path on an older node; the node source in the site's row shape, the finality-paused state, the activation-height guard with --self-test-height; 652f6fc's guard is dropped at that merge), not miner-ui-4's 0.3.15 files; Ember's 995530b and aa73005 fields (watts_total, pounds_per_day, balance_wei, price null with the manifest rule, tune_floor, the finality pause rule fd03f35).
|
||||||
|
- The publish side owes a way to set the manifest's top-level "urgent": true for an emergency rollout through a finality pause (fd03f35 reads it; igneum-ota-sign passes the document through): a publish-manifest.sh flag.
|
||||||
|
- The gate line for every node cut from here (main writes it into CLAUDE.md): a mining new node beside an old node on the live file for ten minutes, the old node's reject count read before and after, and one new node restarted mid-window re-syncing from an old peer.
|
||||||
127
docs/plans/release-0.3.17.md
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
# Release 0.3.17: the node-only fresh-join hotfix (7 October 2026)
|
||||||
|
|
||||||
|
Main's order, 03:3xZ: the canary of the feature tree (now 0.3.18, node 12153428) failed on two further node faults (the idle-peer guard closes every peer mid headers-proof IBD; a window-below-epoch log flood at 1,900 lines a minute), so tonight's cut is the 0.3.16 app tree with the node pin moved to f1ea7a38 plus the IBD-guard fix and nothing else that ships bytes. Fresh joins on the live devnet while the class v4 window is open are what it fixes (ledger N6). The version scheme is three-part everywhere (the app's parser returns None on a fourth part, so "0.3.16.1" would never be newer than 0.3.16); hence 0.3.17 here, the feature tree 0.3.18, decimals 0.3.19.
|
||||||
|
|
||||||
|
## 1. The trees
|
||||||
|
|
||||||
|
| tree | branch | commit | what |
|
||||||
|
|---|---|---|---|
|
||||||
|
| app | release-0.3.17 (origin), from release-0.3.15 a9eb58f1 | see git log | version 0.3.17 (ec5dba41), the hive tar without Mac metadata (00452001, from the 0.3.18 tree's c5187a70), the packaged object = the live sixteen-field object (5d118f58, from bd7b3076), rust-toolchain.toml (6551d341, from 29740ce6), the node pin to follow |
|
||||||
|
| node | release-0.3.17-node (box mirror) | 5899f603 | f1ea7a38 (the live 0.3.16 node) + b3c228fa (hand-port of ca3-v4-0317-fix 90aaf38e: `header_version_acceptable` gated on class_signal_active() alone since this tree has no ladder; the IBD guard and the consensus rule read it; unit test without the ladder assert) + 3dbcf209 (cc78a21f, the integration crate's test-only fixes, main's order) + 5899f603 (rust-toolchain.toml). Shipped bytes differ from f1ea7a38 by the port alone. |
|
||||||
|
|
||||||
|
The feature tree's node branch is release-0.3.18-node (12153428) on the mirror; its app branch release-0.3.18 (origin); its plan docs/plans/release-0.3.18.md.
|
||||||
|
|
||||||
|
## 2. Builds and gates
|
||||||
|
|
||||||
|
| piece | commit string | sha256 (first 8) | bytes | note |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Linux igneumd, box native (glibc 2.39; the fleet's canary sha and the 24.04 boxes) | 5899f603 | d712b498 | 49,719,136 | 03:43Z |
|
||||||
|
| Linux igneum-miner, box native | (none in the miner) | 2d8d069f | 9,979,992 | the miner crate is unchanged from 0.3.15/0.3.16 |
|
||||||
|
| Windows igneumd.exe (cross, box) | 5899f603 | b6b3f417 | 51,894,272 | 03:45Z |
|
||||||
|
| Windows igneum-miner.exe | | 47512bb1 | 11,130,368 | |
|
||||||
|
| Mac igneumd (Apple Silicon) | 5899f603 | f3dcf310 | 41,803,104 | |
|
||||||
|
| Igneum-Miner-0.3.17.dmg | | 75518e2c | 41,948,417 | packaged object = the live sixteen-field object; prover pair aboard; version 0.3.17 |
|
||||||
|
| hands igneumd (box native, the build-server lane's tree, pairs with igneum 250fd371) | 5899f603 | 401bfd54 | 49,720,416 | not byte-equal to d712b498: prost's generated protowire.rs embeds OUT_DIR, and the two worktrees have different paths (the 0.3.14 repro's first class); same inputs otherwise. The "reproduced" row compares two builds in ONE tree. |
|
||||||
|
| seed igneumd (class seed, glibc 2.35 through zig, the build-server lane) | 5899f603 | 39165c1f | 48,437,456 | needs GLIBC_2.34; restart-seed.sh takes it as IGNEUMD_LINUX |
|
||||||
|
| hands / seed igneum-miner | | cad0552f / d1446c13 | 9,979,544 / 10,002,816 | |
|
||||||
|
| Windows inputs | 5899f603 | | | pushed 03:47Z, pin 74250f2c, windows.yml run 37568494498 dispatched 03:49Z |
|
||||||
|
|
||||||
|
Digests read directly on the Mac binary (digest.sh, each with the override file's own lines present): sixteen-field eada4bda, thirteen-field b18ed271, no file c562d70e. Unchanged from 0.3.16: the port changes no parameter.
|
||||||
|
|
||||||
|
Engine read-back on this tree: igneum_getNodeInfo does not exist on f1ea7a38 (it came with the 0.3.18 node; the hotfix RPC answers -32601), so the powEngine column is the binary's link: `strings igneumd | grep -c igneum-pow/src/` above zero (Mac 8, Linux 6, Windows 9; a stub build carries none of the crate's source paths, since consensus/pow/src/igneum.rs picks HeavyHashEngine without the feature). Every build path (build-remote, cross-remote, build-linux.sh) passes --features kaspad/igneum-pow by default.
|
||||||
|
|
||||||
|
Found on the way: master's build tools ended silently on a tree without rust-toolchain.toml (the pin reader); the pin file was added to both hotfix trees (6551d341, 5899f603) and the build-server lane fixed the reader on master (607a7433).
|
||||||
|
|
||||||
|
| node suite on the box (six crates, --no-fail-fast, 03:45Z, box load 44 with two other lanes' suites) | kaspa-consensus 97 + 2 FAILED under load (`antichain_merge_test`, `inactivity_shortcut_block_clamps_to_genesis_within_finality_depth`), each 1/1 green alone at 03:53Z; consensus-core 112 + 7; the rest green | PASS (the two are the known load flakes of the 12153428 suite) |
|
||||||
|
| `cargo check -p kaspa-testing-integration --tests` (with cc78a21f) | rc 0, 03:50Z | PASS |
|
||||||
|
|
||||||
|
| generic Linux igneumd (class seed, glibc 2.35 through zig, my tree) | 5899f603 | 7b582125 | 48,437,392 | GLIBC_2.34; the devnet seed itself runs the build-server lane's 39165c1f (same inputs, other OUT_DIR) |
|
||||||
|
| HiveOS igneumd (class hive, glibc 2.31) | 5899f603 | f45c2014 | 48,438,096 | GLIBC_2.30; pow link 6 |
|
||||||
|
| igneum-hive-0.3.17.tar.gz | | b283ba0c | 24,719,145 | 2.31 node pair + the box's CUDA/OpenCL workers 193ec36f/7a35ff2a (unchanged from 0.3.16); no `._` entries; ubuntu:20.04 container smoke on the box (ldd 2.31): all four binaries load and answer |
|
||||||
|
| Windows run 37568494498 (release-0.3.17 at 74250f2c) | | | | green 03:55Z (engine, window host, payload, installer, smoke run); Igneum-Miner-Setup-0.3.17.exe 474a2070, 62,641,219 bytes |
|
||||||
|
| scratch manifest (r0317hf/dlsite-stage, token and public) | | | | version 0.3.17, mac 75518e2c, windows 474a2070, consensus.override = the live sixteen-field object, HiveOS alias held at 0.3.16 until its own row; written 03:57Z; the live folder holds no 0.3.17 file |
|
||||||
|
|
||||||
|
## 3. The canary (the fleet, c17-1, wiped datadir, node d712b498, live miner 3976c1b2)
|
||||||
|
|
||||||
|
- 03:50Z start (T0 03:50:02Z), headers-proof IBD from 213.173.107.74 on one session throughout; 04:07Z headers 61 percent (69,668), 4 peers, zero SendPingsFlow lines, zero "completed with error", zero reject or wrong-version lines (the one match is the digest banner). 12153428's guard fired at 12 minutes twice; this node was 5 minutes past that mark on one session. Clock: headers through about 04:17Z, blocks synced 04:35Z, ten minutes of mining reads to 04:45Z, then the relay and poison cases.
|
||||||
|
- Fault 2 of the 12153428 canary (the "window below epoch 43 seed block ... incomplete" WARN flood) is f1ea7a38's own line (consensus/src/processes/class_signal.rs:217 in the 0.3.15 tree): live behaviour on every fresh headers IBD since publish 2, not a hotfix regression and not held against it. On this node 49,844 lines, 32 MB in 15 minutes, all epoch 43, bounded by the IBD (a one-time 30 to 60 MB on a fresh install; the supervisor trims node logs on standing boxes). The 0.3.18 node (6e4ace3f) says it once per epoch per process.
|
||||||
|
|
||||||
|
- **Headers through, 04:25:25Z: the decisive read PASSES.** 100 percent (114,000 headers) in one IBD session from 213.173.107.74, pruning point proof and SMT state imported, SendPingsFlow 0 the whole 35 minutes (T0 03:50:02Z), three times the 12 minutes 12153428's guard allowed; the WARN flood held the pace to 40 to 110 headers a second. Block bodies next: synced about 04:45Z, then the ten minutes of mining reads and the cases.
|
||||||
|
|
||||||
|
- 04:4xZ, the block stage: after the batch (91,575 of 114,050 by 04:33Z) the node imports about 3.7 blocks a second at 100 percent of one core, 4.5 GB resident, the exec layer waiting; the log is the finality replay (5,005 "checkpoint determined", 2,529 locks, 78 historical EQUIVOCATION lines by 20 keys, the hub carries 102 of the same); peers 4, pings 0, errors 0. Tip about 06:45Z, the cases after. The hotfix's shipped diff against f1ea7a38 is three files, 36 lines, all the header-version reading (consensus/core/src/igneum.rs, pre_ghostdag_validation.rs, ibd/flow.rs): block import, the finality replay and exec are untouched, so the slow stage is the chain's own cost since the window opened (a 0.3.18 item: the replay's pace on a fresh join). The fleet's f1ea7a38 control on a wiped pod cannot pass the relay block (N6) and answers nothing. Main asked whether the deploy waits for the cases line (about 07:30Z) or goes on the guard read plus the diff argument.
|
||||||
|
|
||||||
|
- Main's ruling, 04:4xZ, (b): deploy on the decisive read plus the diff argument; the synced line and the cases follow on the same pod as the confirmation, and a FAIL there is a 0.3.16-with-the-fix rebuild, not a rollback of the fix. Condition on the standing fleet: ONE box at a time, node restarted and read back, a lock line from the hub before the next; hold if the frozen table's signed share reads under 75 percent (it read 86.8 at 05:44 UK; the voter table had sat at 69 with a 2.3-point margin earlier). The fleet's f1ea7a38 control on c17-poison confirmed N6 live (45 "got 1026" refusals, 15 unsuccessful IBD cycles in two minutes, zero headers).
|
||||||
|
|
||||||
|
## 4. The deploy (04:45Z on)
|
||||||
|
|
||||||
|
- 04:45Z identity grep on the payload UI clean; 04:46Z `publish-manifest.sh --public --deploy` from the live folder (the same inputs as the scratch copy): token and public manifests at 0.3.17, mac 75518e2c, windows 474a2070, consensus.override = the live sixteen-field object (verified on the live URLs), wallet 0.1.5 unchanged, public aliases 200 with the new sizes, the HiveOS alias held at 0.3.16 until its row.
|
||||||
|
- 04:49Z update-nows: d937c69d (Mac) and ae432dc7 queued, 1ccfe586 with --deploy (woke the apps, stamp 3a0fa70e). 04:51Z the Mac and ae432dc7 ran theirs ("update check and install asked"); the Mac's update block reset with updated_from 0.3.16 (relaunch in progress).
|
||||||
|
- Fleet: standing boxes one at a time to d712b498 with lock lines between (the fleet lane); the canary form runs on to the synced line (about 06:25Z at 4.4 blocks a second) and the cases.
|
||||||
|
|
||||||
|
- **Canary synced 04:54:02Z and mining clean** (c17-1, d712b498): 118,243 blocks, headers equal, 4 peers; the live miner 3976c1b2 from 04:54:07Z, 8 blocks accepted by 04:57Z, 54 MH/s on the 3090, got-reject 0, wrong-version 0, templates switching on the tip. The replay stage ended an hour inside the 2-hour estimate. Hub read of its blocks 05:04Z, then the relay and poison cases.
|
||||||
|
- Standing fleet rollout from 04:56Z under main's lock rule: p1-3080 first (frozen table's signed share 93.7 percent at checkpoint 8056 by the folded-certificate reading; the lock line's own figure swings 68 to 87 and is not the gate), read back, synced, miner back, a new lock line before p2-4070-1.
|
||||||
|
- Mac: app 0.3.17 applied 04:51Z (ota-apply.log "0.3.17 is running"); own node pid 20371, bundle igneumd carries 5899f603, pow link 8, consensus_digest eada4bda, override.json = the live sixteen-field object, 4 peers, catching up from DAA 119,362 (173,960 at 04:58Z); the miner stays paused on the project lead's order. The console's "node 2.1.0-<hash>" label is not the running node's commit (a24ab01a is release-0.3.6's), so every commit read is from the binary or the journal.
|
||||||
|
- PC 2 (1ccfe586): offline since 02:00 UK, host unreachable: the console last saw its app about 01:00Z, the update-now job has no uploads, the relay never registered an agent on it (PC2 "never seen"; the app's presence row stale since 17:55Z). Main's rule: leave it for the project lead; it is not his desk.
|
||||||
|
|
||||||
|
- **HiveOS row live 05:02Z**: igneum-hive-0.3.17.tar.gz b283ba0c (24,719,145 bytes) and its .sha256 in dl/public, `publish-public.sh --hive <tar> --deploy --verify`: the public alias rewritten and every public file and alias verified 200 at the local size. (The runbook's first attempt passed no file and copied the tar without moving the alias; fixed in the runbook.)
|
||||||
|
|
||||||
|
- **Canary full form PASS, 05:04Z** (c17-1, wiped datadir, d712b498 / 5899f603): fresh join through the headers proof in one session (03:49:51Z to 04:25:25Z, zero guard lines), synced 04:54:02Z, ten minutes mining with the live miner 3976c1b2 at 51 to 55 MH/s: 52 blocks via submit block, accepted 51, rejected 0, mismatched 0, got-reject 0, wrong-version 0, SendPingsFlow 0, digest eada4bda, pow link 6 (Mac grep) and 5 (box grep); the hub holds 19 blocks by c17-1's key 1f5bce70 in its last 700 with 0 rejects naming the pod. The relay and poison cases started 05:04:25Z as the confirmation.
|
||||||
|
- Fleet rollout under the lock rule (folded-table gate): p1-3080 04:56:13Z, sha d712b498, commit 5899f603, digest eada4bda, pow 5, synced 118,862 with 4 peers, miner back, lock after at checkpoint 8070 (81.3 percent), 59 s; p2-4070-1 05:03:10Z, same read-back, 118,980 blocks, lock at 8073 (81.1), 93 s; p1-4070 next; the 14 done by about 05:30Z with pool-1 and hub-1 last.
|
||||||
|
|
||||||
|
- **PC 1 (ae432dc7), read back 05:00Z by job:** app 0.3.17, node pid 3840 started 04:51:43Z, synced at DAA 251,095 with 4 peers, consensus_digest eada4bda, the installed igneumd.exe carries 5899f603 (3 marks) and the igneum-pow link (9 paths). Its sha 949a30a1 (52,256,768 bytes) differs from the cross exe b6b3f417 (51,894,272) by the CI payload step (windows.yml line 243): on the day's check list. **Fault: the workers did not come back after the relaunch.** The 05:15Z dump: mining state "waiting", paused false, every enabled card (RTX 5090, RTX 4070, RX 9070 XT) "faulted: no status line from the miner for 90 s (restarted once already)", pid 0, 0.0 MH/s since 04:51Z (142.5 MH/s on 0.3.16 at 04:51Z); the node was still syncing when the workers were first started (program: "next program not known yet", boundary 255,600); proving unaffected (segments proven and paid through). Main's word: one `restart --what miners` job (05:1xZ), read back at the 0.3.16 rates; if that does not bring them back, PC 1 waits for the project lead. A 0.3.18 app row for the UI lane: workers must start after an OTA relaunch (main sends the known-failed case).
|
||||||
|
|
||||||
|
## 5. The standing fleet (the fleet lane; rollout 04:56:13Z to 05:27:55Z under the lock rule)
|
||||||
|
|
||||||
|
Every box: sha d712b498ae6ef7de, commit string 5899f603, digest eada4bda, igneum-pow linked (5 paths), synced, miner back, a new lock after.
|
||||||
|
|
||||||
|
| Box | Card | Blocks at read | Peers | Lock after | Table (folded, percent) | Seconds |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| p1-3080 | 3080 | 118,862 | 4 | 8070 | 81.3 | 59 |
|
||||||
|
| p2-4070-1 | 4070 | 118,980 | 4 | 8073 | 81.1 | 93 |
|
||||||
|
| p1-4070 | 4070 | 119,072 | 4 | 8075 | 82.2 | 67 |
|
||||||
|
| p1-a5000 | A5000 | 119,119 | 4 | 8077 | 83.3 | 73 |
|
||||||
|
| p2-3090-1 | 3090 | 119,300 | 4 | 8083 | 74.6 (HOLD under 75; the share fell 83.7 to 72.3 at 8081 when this restart and c17-1's landed inside ten seconds; three checkpoints at 78.1 by 05:11:56Z, resumed 05:12:47Z) | 169 |
|
||||||
|
| p2-3090-2 | 3090 | 119,590 | 4 | 8091 | 78.1 | 87 |
|
||||||
|
| p2-3090-3 | 3090 | 119,840 | 5 | 8098 | 77.4 | 218 |
|
||||||
|
| p2-3090-4 | 3090 (the Vast box the supervisor restarted twice tonight) | 119,388 | 4 | 8102 | 79.4 | 131 |
|
||||||
|
| p1-4090 | 4090 | 120,090 | 5 | 8104 | 81.9 | 73 |
|
||||||
|
| p2-4090-1b | 4090 | 120,167 | 5 | 8109 | 81.1 | 156 |
|
||||||
|
| p2-4090-3 | 4090 | 120,321 | 5 | 8110 | 85.7 | 50 |
|
||||||
|
| p1-5090 | 5090 | 120,390 | 5 | 8112 | 85.9 | 74 |
|
||||||
|
| pool-1 | pool host | 120,445 | 7 | 8114 | 84.4 | 41 |
|
||||||
|
| hub-1 | hub (lock read from p1-4090) | 120,515 | 6 | 8115 | 85.2 | 63 |
|
||||||
|
|
||||||
|
One pass at 05:29Z: 14 of 14 on d712b498 / 5899f603, synced at 120,539 to 120,568, 4 to 20 peers, one miner each; table 85.4 at checkpoint 8117. The registry wants d712b498ae6ef7de on all 14, so the standing loop reports any box that falls back. The pool-v0 rerun is the pool agent's at 05:40Z. The relay and poison cases relaunched 05:30Z (poison node on pool-1's 1026 datadir copy), verdict about 05:55Z. The hands and seed line went to the build-server lane at 05:3xZ.
|
||||||
|
|
||||||
|
## 6. Hands and seed (the build-server lane, 05:30:41Z to 05:32:10Z, after the miners)
|
||||||
|
|
||||||
|
| Node | Binary | Commit string | Digest | Engine (binary link) | First executing line | Down |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
| observer node (box, igneum-observer-node) | /srv/hands/bin/igneumd-2.1.0-5899f603, sha 401bfd54 | 5899f603 | eada4bda MATCH | igneum-pow (6 paths; the RPC answers -32601 on this tree) | 05:30:50Z "exec state loaded from a snapshot: tip 157682"; PoW accepted from 05:30:51Z (DAA 253,257 on); the observer process reconnected by itself | 6 s |
|
||||||
|
| node 1 (box, igneum-node1) | same binary | 5899f603 | MATCH | igneum-pow | 05:31:16Z tip 157689; PoW accepted from 05:31:18Z; 1 listener on 26611, 11 peers, 126 blocks accepted in the next minute; /api/live age 0.6 s at 157690 | 6 s |
|
||||||
|
| seed (188.245.5.161, igneumd-v4) | /opt/igneum/v4/bin/igneumd sha 39165c1f (class seed, GLIBC 2.34 on the seed's 2.36) | 5899f603 | eada4bda, "class v4 from the override file: active from epoch 231" | igneum-pow (6 paths) | unit active 05:32:00Z pid 155580; 05:32:03Z tip 157696, exec sync resumed from the snapshot; 265 blocks accepted in two minutes | 24 s |
|
||||||
|
|
||||||
|
The object on all three: the live manifest's consensus.override (16 fields, floor 831,600, window 86,400, byte-equal to r0315/ov16-live.json). The seed's igneum-miner was not installed (restart-seed.sh carries only igneumd, as before). Nothing DIFFERed. The 12153428 pairs stay held for 0.3.18.
|
||||||
|
|
||||||
|
## 7. PC 1 after the restart-miners job
|
||||||
|
|
||||||
|
The `restart --what miners` job ran 05:18:09Z ("miners restarted"); the 05:32Z dump reads the same as 05:15Z: mining "waiting", paused false, every enabled card "faulted: no status line from the miner for 90 s (restarted once already)", pid 0, faults 0, hash 0.0, program "next program not known yet" (boundary 255,600, 36 minutes off), node synced at DAA 253,048 with 5 peers. Main's rule: PC 1 waits for the project lead with these lines.
|
||||||
|
|
||||||
|
**The cause, from PC 1's own logs (05:4xZ):** the node read synced=true from the first second after the relaunch (watch log 1791348704: blocks 118,118, DAA 250,572; every line after); the engine wrote "[ok] node synced" at +1 s and started the three miners at +14, +25 and +38 s. Each subscribed to NewBlockTemplate, printed its identities and votes, then every template fetch timed out at 5 s for all eight identities for the whole 90 s ("template fetch timed out (5 s) for identity N; mining continues on the current template") while the node ran its finality catch-up after the restart (the node log: checkpoints 8053 to 8055 locking at 04:54Z, "certificate kept pending until the chain decides"). With no template the miner printed no status line; the watchdog restarted each miner once at 90 s, the restarted miner hit the same timeouts, and at 180 s every card was marked faulted. The `restart --what miners` job at 05:18Z left faulted cards faulted. The old engine's last status lines (142 to 168 MH/s) are stamped 04:02 to 04:08Z (their "up 05:5x" is uptime), 43 minutes before the apply. Two halves: the node's template RPC blocks under the finality replay after a restart on a synced node (the node lane; the same lock as the fresh-join cost work, 8220c944), and the app's watchdog faults a miner on silence when the cause is the node not answering templates (the UI lane; bac43ac4's "unsynced node" reading was wrong, the rule is "template timeouts while the node reports synced are not a card fault"). Lines: scratchpad/r0317hf/pc1-fault/relaunch-engine-and-workers.txt. The dumps: scratchpad/r0317hf/pc1-fault/state-mining-0515Z.json and state-mining-after-restart.json. Proving on PC 1 unaffected throughout.
|
||||||
|
|
||||||
|
## 8. The Mac, and "0.3.17 live" (05:4xZ)
|
||||||
|
|
||||||
|
Mac (d937c69d): app 0.3.17 applied 04:51:43Z; own node pid 20371, the bundle's igneumd carries 5899f603 (2 marks) and the igneum-pow link (8 paths), consensus_digest eada4bda, override.json = the live sixteen-field object; the node caught up from DAA 119,362 through the headers batch and the block replay (93,704 of 118,131 blocks at 05:30Z, about 4 a second) and read synced at 05:36Z: DAA 254,195, headers equal blocks at 121,741, 4 peers. The miner stays paused on the project lead's order.
|
||||||
|
|
||||||
|
**0.3.17 is live** on every node that can take it: the 14 standing fleet boxes, the Mac, PC 1 (node; the workers wait for the project lead), the observer node, node 1 and the seed, all on commit string 5899f603, digest eada4bda, igneum-pow linked. PC 2 is offline since 02:00 UK (host unreachable) and takes the manifest when it returns. The canary's relay and poison cases run on as confirmation (verdict about 05:55Z); a FAIL there is a 0.3.16-with-the-fix rebuild by main's rule, not a rollback. The Discord card posts on this line.
|
||||||
|
|
||||||
|
|
||||||
|
## 9. The cases (confirmation, interim 06:25Z)
|
||||||
|
|
||||||
|
- c17-1 (d712b498) holds the live chain: wrong-version 0, from-relay 0, synced at 125,034 (hub 125,0xx), peers the two hands, the hub and pool-1; got-reject 5, all "traversal error: passed max allowed traversal (721 > 720)" from the hands at 06:04 to 06:08Z on the pool's 480-DAA-stale blocks c17-1 relayed (the pool daemon's fault, fixed in the pool lane), not a version matter. The hub (d712b498 since 05:27Z) has logged zero "wrong block version" lines since the hotfix (its only ones are yesterday's 22:28Z home-miner refusals).
|
||||||
|
- Relay 6615571c on the 1026 datadir: 19 peers, 1,238 blocks relayed, wrong-version 0; the hub dialled it outbound at 06:05:36Z and dropped it after one second on the traversal rule (its blocks are the pre-window 1026 chain, 721 deep), then took it inbound; 0 PoW accepted from it, the right outcome. The relay-into-the-target case is NOT exercised on c17-1: both pods sit on the same RunPod host (64.119.209.250) and the dial to the host's public port does not hairpin (0 lines for the relay's address in c17-1's log). On 0.3.18 the target c18-1 is on another host and the dial works.
|
||||||
|
- Poison 713ef876: synced from the relay 06:22Z, mining (accepted 0 at +83 s, about 3 minutes per block on a 3090 at today's difficulty), got-reject 1. Mid-window restart of c17-1 at +300 s, hub read at +720 s, then the verdict line.
|
||||||
|
|
||||||
|
**Cases verdict, 06:35Z: PASS on every read the form could take on c17-1 (d712b498).** Over the 12-minute window (poison 713ef876 mining on the 1026 datadir, relay 6615571c with 20 peers): c17-1 wrong-version 0, from-relay 0, got-reject 5 (the hands' traversal drops at 06:04 to 06:08Z on the pool's stale blocks; none after), synced throughout at the hub's height (125,871 at 06:35Z); the mid-window restart at 06:28:47Z with the hub as its only peer re-synced in 3 s. Poison: 47 blocks accepted by its own node, 0 rejected, got-reject 1. Relay: 1,680 blocks relayed, wrong-version 0, got-reject 31 (the hands' traversal drops on the pre-window 1026 chain, the right outcome). Hub: "wrong block version" 0 today on the hotfix, rejects naming c17-1 0; the clean form's read of 19 blocks held by c17-1's key at 05:04Z stands (its miner was off after the restart). VOID: the relay-into-target case (c17-1 never connected to the relay: same RunPod host, no hairpin); re-run on 0.3.18 with pods on separate hosts. Pods c17-1, c17-relay, c17-poison destroyed.
|
||||||
|
|
||||||
|
**0.3.17 closes on this line.** Open for the next tree: PC 1's workers (the two rules in 0.3.18), PC 2 offline, the CI payload exe sha difference on the day's check list, the relay-into-target read on 0.3.18.
|
||||||