Igneum Miner: node state derived from every watch reading (caught up within 2 headers and moving beats a false getInfo flag), an accepted block in the last minute is proof of sync, live height/daa/peers on the node tile; state-machine test for syncing -> clock-refused -> catch-up -> steady (PC 2 showed syncing while mining at 118 MH/s)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
2309c8d9b0
commit
0d4498ed35
9 changed files with 710 additions and 17 deletions
98
app/igneum-app/Cargo.lock
generated
98
app/igneum-app/Cargo.lock
generated
|
|
@ -66,6 +66,33 @@ dependencies = [
|
|||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "4.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"curve25519-dalek-derive",
|
||||
"digest",
|
||||
"fiat-crypto",
|
||||
"rustc_version",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek-derive"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.7.10"
|
||||
|
|
@ -98,6 +125,30 @@ dependencies = [
|
|||
"spki",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519"
|
||||
version = "2.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||
dependencies = [
|
||||
"pkcs8",
|
||||
"signature",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||
dependencies = [
|
||||
"curve25519-dalek",
|
||||
"ed25519",
|
||||
"serde",
|
||||
"sha2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "elliptic-curve"
|
||||
version = "0.13.8"
|
||||
|
|
@ -127,6 +178,12 @@ dependencies = [
|
|||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.9"
|
||||
|
|
@ -164,11 +221,13 @@ dependencies = [
|
|||
name = "igneum-app"
|
||||
version = "0.3.0"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"getrandom",
|
||||
"k256",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha3",
|
||||
]
|
||||
|
||||
|
|
@ -254,6 +313,15 @@ dependencies = [
|
|||
"getrandom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc_version"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
|
||||
dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sec1"
|
||||
version = "0.7.3"
|
||||
|
|
@ -268,6 +336,12 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
|
|
@ -295,7 +369,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -311,6 +385,17 @@ dependencies = [
|
|||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha3"
|
||||
version = "0.10.9"
|
||||
|
|
@ -346,6 +431,17 @@ version = "2.6.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.6"
|
||||
|
|
|
|||
|
|
@ -11,12 +11,20 @@ build = "build.rs" # Windows targets: links resources/igneum-app.rc (coin icon
|
|||
name = "igneum-app"
|
||||
path = "src/main.rs"
|
||||
|
||||
# the publisher's manifest signer (packaging/ota/publish-manifest.sh); built here, never shipped
|
||||
[[bin]]
|
||||
name = "igneum-ota-sign"
|
||||
path = "src/bin/ota-sign.rs"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
k256 = { version = "0.13", default-features = false, features = ["arithmetic", "std"] }
|
||||
sha3 = { version = "0.10", default-features = false }
|
||||
getrandom = "0.2"
|
||||
# over-the-air updates (src/manifest.rs, src/ota.rs): Ed25519 manifest signatures and sha256 of the downloads
|
||||
ed25519-dalek = { version = "2", default-features = false, features = ["std"] }
|
||||
sha2 = { version = "0.10", default-features = false }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
|
|
|||
95
app/igneum-app/src/bin/ota-sign.rs
Normal file
95
app/igneum-app/src/bin/ota-sign.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! igneum-ota-sign: the publisher's side of the update manifest. Built with the app (cargo build) but never shipped.
|
||||
//! It includes src/manifest.rs as is, so the bytes it signs are verified by the same code the app runs.
|
||||
//!
|
||||
//! igneum-ota-sign keygen <private-key-file> <public-key-file> 32-byte seed as hex (0600) and the public key as hex
|
||||
//! igneum-ota-sign sign <private-key-file> <manifest.json> prints the detached signature (128 hex)
|
||||
//! igneum-ota-sign verify <public-key-file|hex> <manifest.json> <sig-file> exit 0 when it verifies and parses
|
||||
//! igneum-ota-sign embedded prints the public key compiled into the app and its fingerprint
|
||||
//! igneum-ota-sign fingerprint <public-key-file|hex>
|
||||
//! igneum-ota-sign sha256 <file> the file's sha256 and size, for the manifest
|
||||
|
||||
#[path = "../manifest.rs"]
|
||||
mod manifest;
|
||||
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use std::path::Path;
|
||||
|
||||
fn read_key_arg(a: &str) -> String {
|
||||
if Path::new(a).is_file() {
|
||||
std::fs::read_to_string(a).unwrap_or_default().trim().to_string()
|
||||
} else {
|
||||
a.trim().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn die(msg: &str) -> ! {
|
||||
eprintln!("igneum-ota-sign: {msg}");
|
||||
std::process::exit(2)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
match args.first().map(|s| s.as_str()) {
|
||||
Some("keygen") if args.len() == 3 => {
|
||||
if Path::new(&args[1]).exists() {
|
||||
die(&format!("{} exists; not overwriting a signing key", args[1]));
|
||||
}
|
||||
let mut seed = [0u8; 32];
|
||||
getrandom::getrandom(&mut seed).expect("os randomness");
|
||||
let sk = SigningKey::from_bytes(&seed);
|
||||
let pk = manifest::hex_encode(sk.verifying_key().as_bytes());
|
||||
if let Some(d) = Path::new(&args[1]).parent() {
|
||||
let _ = std::fs::create_dir_all(d);
|
||||
}
|
||||
std::fs::write(&args[1], format!("{}\n", manifest::hex_encode(&seed))).unwrap_or_else(|e| die(&e.to_string()));
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&args[1], std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
std::fs::write(&args[2], format!("{pk}\n")).unwrap_or_else(|e| die(&e.to_string()));
|
||||
println!("public key {pk}");
|
||||
println!("fingerprint sha256:{}", manifest::fingerprint(&pk));
|
||||
}
|
||||
Some("sign") if args.len() == 3 => {
|
||||
let seed = manifest::hex_decode(&read_key_arg(&args[1])).unwrap_or_else(|| die("private key is not hex"));
|
||||
let seed: [u8; 32] = seed.try_into().unwrap_or_else(|_| die("private key is not 32 bytes"));
|
||||
let sk = SigningKey::from_bytes(&seed);
|
||||
let bytes = std::fs::read(&args[2]).unwrap_or_else(|e| die(&format!("{}: {e}", args[2])));
|
||||
let text = std::str::from_utf8(&bytes).unwrap_or_else(|_| die("manifest is not UTF-8"));
|
||||
manifest::parse(text).unwrap_or_else(|e| die(&format!("refusing to sign: {e}")));
|
||||
println!("{}", manifest::hex_encode(&sk.sign(&bytes).to_bytes()));
|
||||
}
|
||||
Some("verify") if args.len() == 4 => {
|
||||
let pk = read_key_arg(&args[1]);
|
||||
let bytes = std::fs::read(&args[2]).unwrap_or_else(|e| die(&format!("{}: {e}", args[2])));
|
||||
let sig = std::fs::read_to_string(&args[3]).unwrap_or_else(|e| die(&format!("{}: {e}", args[3])));
|
||||
match manifest::verify_and_parse(&bytes, sig.trim(), &pk) {
|
||||
Ok(m) => println!("ok: version {} ({}), mac {}, windows {}", m.version, m.channel, m.mac.map(|e| e.url).unwrap_or_else(|| "none".into()), m.windows.map(|e| e.url).unwrap_or_else(|| "none".into())),
|
||||
Err(e) => die(&e),
|
||||
}
|
||||
}
|
||||
Some("embedded") if args.len() == 1 => {
|
||||
println!("{}", manifest::OTA_PUBLIC_KEY_HEX);
|
||||
println!("fingerprint sha256:{}", manifest::fingerprint(manifest::OTA_PUBLIC_KEY_HEX));
|
||||
}
|
||||
Some("fingerprint") if args.len() == 2 => {
|
||||
let pk = read_key_arg(&args[1]);
|
||||
if manifest::public_key(&pk).is_err() {
|
||||
die("not a public key");
|
||||
}
|
||||
println!("{pk}");
|
||||
println!("fingerprint sha256:{}", manifest::fingerprint(&pk));
|
||||
}
|
||||
Some("sha256") if args.len() == 2 => {
|
||||
let p = Path::new(&args[1]);
|
||||
let sum = manifest::sha256_file(p).unwrap_or_else(|e| die(&format!("{}: {e}", args[1])));
|
||||
let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
|
||||
println!("{sum} {size}");
|
||||
}
|
||||
_ => {
|
||||
eprintln!("usage: igneum-ota-sign keygen <priv> <pub> | sign <priv> <manifest.json> | verify <pub> <manifest.json> <sig> | embedded | fingerprint <pub> | sha256 <file>");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -345,6 +345,7 @@ pub struct Engine {
|
|||
clock_https: Option<(f64, Instant)>,
|
||||
clock_next_https: Instant,
|
||||
clock_last_sample: Instant,
|
||||
last_accepted: Option<Instant>,
|
||||
last_settings_save: Instant,
|
||||
last_error_event: Instant,
|
||||
}
|
||||
|
|
@ -402,6 +403,7 @@ impl Engine {
|
|||
clock_https: None,
|
||||
clock_next_https: now + Duration::from_secs(5),
|
||||
clock_last_sample: now,
|
||||
last_accepted: None,
|
||||
last_settings_save: now,
|
||||
last_error_event: now - Duration::from_secs(600),
|
||||
}
|
||||
|
|
@ -1084,8 +1086,9 @@ impl Engine {
|
|||
self.start_watch();
|
||||
}
|
||||
// no reading in a while: the node is away
|
||||
let accepted_recent = self.last_accepted.map(|t| now.duration_since(t) <= Duration::from_secs(60)).unwrap_or(false);
|
||||
if let Some(t) = self.node_last_reading {
|
||||
if now.duration_since(t) > Duration::from_secs(45) {
|
||||
if now.duration_since(t) > Duration::from_secs(45) && !accepted_recent {
|
||||
let mut st = self.st();
|
||||
if st.node.state == "synced" {
|
||||
st.node.state = "syncing".into();
|
||||
|
|
@ -1349,22 +1352,20 @@ impl Engine {
|
|||
let tips = kv_u64(text, "tips").unwrap_or(0);
|
||||
let blue = kv_u64(text, "blue").unwrap_or(0);
|
||||
let difficulty = kv_f64(text, "difficulty").unwrap_or(0.0);
|
||||
let flag = kv(text, "synced").map(|s| s.to_string());
|
||||
let flag = kv(text, "synced").map(|s| s == "true");
|
||||
let was_synced = self.st().node.synced;
|
||||
// the rule of today's launchers: the node's own flag when it carries one, else peers > 0, blocks caught up
|
||||
// with the headers and the count moving (or stable for 60 s); a private test node with unsynced mining
|
||||
// counts as synced once it answers
|
||||
let synced = if self.shared.runtime.unsynced_mining && self.shared.runtime.peers.is_empty() {
|
||||
true // a private test node answering its RPC (genesis alone reads as 0 blocks)
|
||||
} else if let Some(f) = flag.as_deref() {
|
||||
f == "true" && peers > 0
|
||||
} else if peers > 0 && blocks >= headers && blocks > 1 {
|
||||
let stable = self.sync_stable_since.get_or_insert(now);
|
||||
(self.sync_prev.map(|p| blocks > p).unwrap_or(false)) || now.duration_since(*stable) >= Duration::from_secs(60)
|
||||
// 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).
|
||||
let caught_up_moving = self.sync_prev.map(|p| blocks > p).unwrap_or(false);
|
||||
if peers > 0 && headers <= blocks + 2 && blocks > 1 {
|
||||
self.sync_stable_since.get_or_insert(now);
|
||||
} else {
|
||||
self.sync_stable_since = None;
|
||||
false
|
||||
};
|
||||
}
|
||||
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 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);
|
||||
self.sync_prev = Some(blocks);
|
||||
// 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) {
|
||||
|
|
@ -1392,8 +1393,10 @@ impl Engine {
|
|||
String::new()
|
||||
} else if peers == 0 {
|
||||
"waiting for a peer".into()
|
||||
} else {
|
||||
} else if headers > blocks + 2 {
|
||||
format!("{blocks} of {headers} blocks")
|
||||
} else {
|
||||
"caught up, waiting for the next block".into()
|
||||
};
|
||||
}
|
||||
if synced && !was_synced {
|
||||
|
|
@ -1439,6 +1442,16 @@ impl Engine {
|
|||
return;
|
||||
}
|
||||
if text.contains(" ACCEPTED block") {
|
||||
self.last_accepted = Some(Instant::now());
|
||||
{
|
||||
// the node accepted our block: it is at the tip, whatever its flag says
|
||||
let mut st = self.st();
|
||||
if !st.node.synced {
|
||||
st.node.synced = true;
|
||||
st.node.state = "synced".into();
|
||||
st.node.message = String::new();
|
||||
}
|
||||
}
|
||||
let unix = crate::platform::unix_now_f();
|
||||
let total = {
|
||||
let mut s = self.shared.settings.lock().unwrap();
|
||||
|
|
@ -1629,6 +1642,26 @@ impl Engine {
|
|||
}
|
||||
}
|
||||
|
||||
/// One `igneum-miner watch` reading.
|
||||
pub struct Reading {
|
||||
pub blocks: u64,
|
||||
pub headers: u64,
|
||||
pub peers: u64,
|
||||
/// getInfo's is_synced, when the line carries one
|
||||
pub flag: Option<bool>,
|
||||
}
|
||||
|
||||
/// Is the node synced? Any of: a private test node; our block accepted in the last minute; the node's own flag;
|
||||
/// caught up (headers within 2 of blocks, a peer, more than genesis) and the count moving or stable for 60 s.
|
||||
/// A false flag never overrides caught-up-and-moving: getInfo said false on PC 2 while it mined (4 October 2026).
|
||||
pub fn sync_decision(r: &Reading, moving: bool, stable_s: f64, accepted_recent: bool, private: bool) -> bool {
|
||||
if private || accepted_recent || r.flag == Some(true) {
|
||||
return true;
|
||||
}
|
||||
let caught_up = r.peers > 0 && r.headers <= r.blocks + 2 && r.blocks > 1;
|
||||
caught_up && (moving || stable_s >= 60.0)
|
||||
}
|
||||
|
||||
/// "...block timestamp is 1791112663000 but maximum timestamp allowed is 1791112600000" -> at least 63 s behind.
|
||||
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();
|
||||
|
|
@ -1641,6 +1674,39 @@ fn behind_from_warning(text: &str) -> Option<f64> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{sync_decision, Reading};
|
||||
|
||||
fn r(blocks: u64, headers: u64, peers: u64, flag: Option<bool>) -> Reading {
|
||||
Reading { blocks, headers, peers, flag }
|
||||
}
|
||||
|
||||
/// PC 2, 4 October 2026: syncing, then every relayed block refused (clock), then the catch-up burst, then steady.
|
||||
#[test]
|
||||
fn sync_state_machine() {
|
||||
// syncing: headers run ahead of blocks
|
||||
assert!(!sync_decision(&r(0, 500, 1, Some(false)), false, 0.0, false, false));
|
||||
// blocks refused for a slow clock: headers climb, blocks do not, nothing accepted
|
||||
assert!(!sync_decision(&r(0, 600, 1, Some(false)), false, 0.0, false, false));
|
||||
assert!(!sync_decision(&r(0, 700, 1, Some(false)), false, 120.0, false, false));
|
||||
// the clock is fixed: the catch-up burst, blocks moving but still behind the headers
|
||||
assert!(!sync_decision(&r(46, 700, 1, Some(false)), true, 0.0, false, false));
|
||||
assert!(!sync_decision(&r(400, 700, 1, Some(false)), true, 0.0, false, false));
|
||||
// caught up and moving: synced, whatever getInfo's flag says
|
||||
assert!(sync_decision(&r(699, 700, 1, Some(false)), true, 0.0, false, false));
|
||||
assert!(sync_decision(&r(700, 700, 1, None), true, 0.0, false, false));
|
||||
// steady: one reading with the same count (a quiet 10 s) is still synced once stable for a minute
|
||||
assert!(!sync_decision(&r(700, 700, 1, Some(false)), false, 10.0, false, false));
|
||||
assert!(sync_decision(&r(700, 700, 1, Some(false)), false, 60.0, false, false));
|
||||
// our block accepted in the last minute: proof of sync even with a stale reading
|
||||
assert!(sync_decision(&r(0, 700, 1, Some(false)), false, 0.0, true, false));
|
||||
// the node's own flag
|
||||
assert!(sync_decision(&r(650, 700, 1, Some(true)), false, 0.0, false, false));
|
||||
// no peer: never synced from the numbers alone
|
||||
assert!(!sync_decision(&r(700, 700, 0, None), true, 100.0, false, false));
|
||||
// a private test node
|
||||
assert!(sync_decision(&r(0, 0, 0, None), false, 0.0, false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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";
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ mod platform;
|
|||
mod procs;
|
||||
mod server;
|
||||
mod state;
|
||||
mod manifest;
|
||||
mod ota;
|
||||
mod update;
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
|
|
|
|||
422
app/igneum-app/src/manifest.rs
Normal file
422
app/igneum-app/src/manifest.rs
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
//! The over-the-air update manifest: what the downloads host publishes as `igneum-app-latest.json` with a detached
|
||||
//! Ed25519 signature next to it (`igneum-app-latest.json.sig`, 64 bytes as 128 hex characters). The signature is over
|
||||
//! the exact bytes of the manifest file; the publisher (packaging/ota/publish-manifest.sh) writes the file in canonical
|
||||
//! form (sorted keys, no whitespace) and the app verifies the bytes before it parses them.
|
||||
//!
|
||||
//! This module is self-contained (serde_json, ed25519-dalek, sha2 only), so the signer binary
|
||||
//! (src/bin/ota-sign.rs) includes it with `#[path]` and signs with the very code that verifies.
|
||||
//!
|
||||
//! Manifest shape:
|
||||
//! {
|
||||
//! "version": "0.3.1", "published_at": "2026-10-04T13:00:00Z", "channel": "devnet",
|
||||
//! "platforms": { "mac": {"url","sha256","size","kind":"dmg"|"zip"}, "windows": {"url","sha256","size","kind":"inno-setup"} },
|
||||
//! "min_supported_version": "0.3.0", "notes": "one line",
|
||||
//! "consensus": { "activation_height": null|number, "deadline_note": "" }
|
||||
//! }
|
||||
//! A platform that is missing is not updated (the Windows build lands later than the Mac one).
|
||||
|
||||
#![allow(dead_code)]
|
||||
|
||||
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// The public half of ~/.config/igneum/ota-signing-key (generated once on the Mac with `igneum-ota-sign keygen`;
|
||||
/// the private key never enters the repo or CI). Fingerprint: SHA-256 of these 32 bytes, see `fingerprint()`.
|
||||
pub const OTA_PUBLIC_KEY_HEX: &str = "b3c9c5bd144e9d246dc0edf897387d4f3f7ca494cd47457123d1c2f892cabddd";
|
||||
|
||||
/// How many DAA blocks before a consensus activation height the app stops waiting for a safe moment.
|
||||
pub const FORK_URGENT_BLOCKS: u64 = 1_800;
|
||||
/// No apply within this many seconds of an hourly program boundary.
|
||||
pub const BOUNDARY_GUARD_S: i64 = 180;
|
||||
/// A ready update that found no safe moment for this long is applied anyway (an unsynced node mines nothing).
|
||||
pub const SAFE_MOMENT_PATIENCE_S: u64 = 6 * 3600;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct PlatformEntry {
|
||||
pub url: String,
|
||||
pub sha256: String,
|
||||
pub size: u64,
|
||||
pub kind: String, // dmg | zip | inno-setup
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub struct Manifest {
|
||||
pub version: String,
|
||||
pub published_at: String,
|
||||
pub channel: String,
|
||||
pub mac: Option<PlatformEntry>,
|
||||
pub windows: Option<PlatformEntry>,
|
||||
pub min_supported_version: String,
|
||||
pub notes: String,
|
||||
pub activation_height: Option<u64>,
|
||||
pub deadline_note: String,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
pub fn platform(&self, name: &str) -> Option<&PlatformEntry> {
|
||||
match name {
|
||||
"mac" => self.mac.as_ref(),
|
||||
"windows" => self.windows.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
pub fn this_platform(&self) -> Option<&PlatformEntry> {
|
||||
self.platform(platform_name())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn platform_name() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"mac"
|
||||
} else if cfg!(windows) {
|
||||
"windows"
|
||||
} else {
|
||||
"linux"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hex_decode(s: &str) -> Option<Vec<u8>> {
|
||||
let s = s.trim();
|
||||
if s.len() % 2 != 0 {
|
||||
return None;
|
||||
}
|
||||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()).collect()
|
||||
}
|
||||
|
||||
pub fn hex_encode(b: &[u8]) -> String {
|
||||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||||
}
|
||||
|
||||
pub fn public_key(hex: &str) -> Result<VerifyingKey, String> {
|
||||
let bytes = hex_decode(hex).ok_or("public key is not hex")?;
|
||||
let arr: [u8; 32] = bytes.try_into().map_err(|_| "public key is not 32 bytes")?;
|
||||
VerifyingKey::from_bytes(&arr).map_err(|e| format!("public key invalid: {e}"))
|
||||
}
|
||||
|
||||
/// SHA-256 of the raw public key bytes, as hex: what the report and the docs quote.
|
||||
pub fn fingerprint(pub_hex: &str) -> String {
|
||||
match hex_decode(pub_hex) {
|
||||
Some(b) => hex_encode(&Sha256::digest(&b)),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the detached signature (hex) over the manifest bytes with the given public key (hex).
|
||||
pub fn verify_signature(manifest_bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result<(), String> {
|
||||
let key = public_key(pub_hex)?;
|
||||
let sig = hex_decode(sig_hex).ok_or("signature is not hex")?;
|
||||
let sig: [u8; 64] = sig.try_into().map_err(|_| "signature is not 64 bytes")?;
|
||||
let sig = Signature::from_bytes(&sig);
|
||||
key.verify(manifest_bytes, &sig).map_err(|_| "manifest signature does not verify".to_string())
|
||||
}
|
||||
|
||||
/// Parses the manifest JSON (after the signature was checked).
|
||||
pub fn parse(text: &str) -> Result<Manifest, String> {
|
||||
let v: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("manifest is not JSON: {e}"))?;
|
||||
let s = |v: &serde_json::Value, k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
|
||||
let version = s(&v, "version");
|
||||
if parse_version(&version).is_none() {
|
||||
return Err(format!("manifest version '{version}' is not a version"));
|
||||
}
|
||||
let entry = |name: &str| -> Result<Option<PlatformEntry>, String> {
|
||||
let Some(p) = v.get("platforms").and_then(|p| p.get(name)) else { return Ok(None) };
|
||||
if p.is_null() {
|
||||
return Ok(None);
|
||||
}
|
||||
let e = PlatformEntry { url: s(p, "url"), sha256: s(p, "sha256").to_ascii_lowercase(), size: p.get("size").and_then(|x| x.as_u64()).unwrap_or(0), kind: s(p, "kind") };
|
||||
if !e.url.starts_with("https://") {
|
||||
return Err(format!("{name}: the url is not https"));
|
||||
}
|
||||
if e.sha256.len() != 64 || !e.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(format!("{name}: sha256 is not 64 hex characters"));
|
||||
}
|
||||
if e.size == 0 {
|
||||
return Err(format!("{name}: size is missing"));
|
||||
}
|
||||
if !["dmg", "zip", "inno-setup"].contains(&e.kind.as_str()) {
|
||||
return Err(format!("{name}: kind '{}' is unknown", e.kind));
|
||||
}
|
||||
Ok(Some(e))
|
||||
};
|
||||
let consensus = v.get("consensus").cloned().unwrap_or(serde_json::Value::Null);
|
||||
Ok(Manifest {
|
||||
version,
|
||||
published_at: s(&v, "published_at"),
|
||||
channel: s(&v, "channel"),
|
||||
mac: entry("mac")?,
|
||||
windows: entry("windows")?,
|
||||
min_supported_version: s(&v, "min_supported_version"),
|
||||
notes: s(&v, "notes"),
|
||||
activation_height: consensus.get("activation_height").and_then(|x| x.as_u64()),
|
||||
deadline_note: s(&consensus, "deadline_note"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Verifies, then parses.
|
||||
pub fn verify_and_parse(manifest_bytes: &[u8], sig_hex: &str, pub_hex: &str) -> Result<Manifest, String> {
|
||||
verify_signature(manifest_bytes, sig_hex, pub_hex)?;
|
||||
let text = std::str::from_utf8(manifest_bytes).map_err(|_| "manifest is not UTF-8")?;
|
||||
parse(text)
|
||||
}
|
||||
|
||||
/// SHA-256 of a file, streamed, as hex.
|
||||
pub fn sha256_file(path: &std::path::Path) -> std::io::Result<String> {
|
||||
use std::io::Read;
|
||||
let mut f = std::fs::File::open(path)?;
|
||||
let mut h = Sha256::new();
|
||||
let mut buf = vec![0u8; 1 << 20];
|
||||
loop {
|
||||
let n = f.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
h.update(&buf[..n]);
|
||||
}
|
||||
Ok(hex_encode(&h.finalize()))
|
||||
}
|
||||
|
||||
// ---- versions -----------------------------------------------------------------------------------------------
|
||||
|
||||
/// major.minor.patch plus an optional pre-release tag ("0.4.0-rc1" sorts before "0.4.0"). A leading "v" is allowed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Version {
|
||||
pub parts: [u64; 3],
|
||||
pub pre: Option<String>,
|
||||
}
|
||||
|
||||
pub fn parse_version(s: &str) -> Option<Version> {
|
||||
let s = s.trim().trim_start_matches('v');
|
||||
if s.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (num, pre) = match s.split_once('-') {
|
||||
Some((n, p)) if !p.is_empty() => (n, Some(p.to_string())),
|
||||
Some(_) => return None,
|
||||
None => (s, None),
|
||||
};
|
||||
let mut parts = [0u64; 3];
|
||||
let mut n = 0;
|
||||
for p in num.split('.') {
|
||||
if n >= 3 || p.is_empty() {
|
||||
return None;
|
||||
}
|
||||
parts[n] = p.parse().ok()?;
|
||||
n += 1;
|
||||
}
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
Some(Version { parts, pre })
|
||||
}
|
||||
|
||||
impl PartialOrd for Version {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for Version {
|
||||
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
|
||||
match self.parts.cmp(&other.parts) {
|
||||
std::cmp::Ordering::Equal => match (&self.pre, &other.pre) {
|
||||
(None, None) => std::cmp::Ordering::Equal,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(Some(a), Some(b)) => a.cmp(b),
|
||||
},
|
||||
o => o,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `latest` is a newer version than `current`. Unparseable input is never newer.
|
||||
pub fn newer(latest: &str, current: &str) -> bool {
|
||||
match (parse_version(latest), parse_version(current)) {
|
||||
(Some(a), Some(b)) => a > b,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- when to apply --------------------------------------------------------------------------------------------
|
||||
|
||||
/// What the engine knows when it asks whether now is a safe moment.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Moment {
|
||||
pub node_synced: bool,
|
||||
/// DAA blocks (about seconds) to the next hourly program boundary; None when the node has not said.
|
||||
pub boundary_eta_s: Option<i64>,
|
||||
/// A worker is starting, exporting a pack or being built: let it finish.
|
||||
pub miner_busy: bool,
|
||||
/// How long the update has been ready and waiting.
|
||||
pub ready_for_s: u64,
|
||||
/// A consensus activation is close, or this version is below min_supported_version: now beats later.
|
||||
pub urgent: bool,
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
if m.urgent {
|
||||
return Ok(());
|
||||
}
|
||||
if m.ready_for_s >= SAFE_MOMENT_PATIENCE_S {
|
||||
return Ok(());
|
||||
}
|
||||
if !m.node_synced {
|
||||
return Err("waiting for the node to sync".into());
|
||||
}
|
||||
if let Some(eta) = m.boundary_eta_s {
|
||||
if (0..=BOUNDARY_GUARD_S).contains(&eta) {
|
||||
return Err(format!("hourly program boundary in {} s; installing after it", eta.max(1)));
|
||||
}
|
||||
}
|
||||
if m.miner_busy {
|
||||
return Err("a worker is starting; installing once it runs".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A consensus activation is within FORK_URGENT_BLOCKS of the node's DAA score (and the node has a score).
|
||||
pub fn fork_is_close(activation_height: Option<u64>, daa: u64) -> bool {
|
||||
match activation_height {
|
||||
Some(h) if daa > 0 => daa.saturating_add(FORK_URGENT_BLOCKS) >= h,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// `current` is older than the manifest's min_supported_version.
|
||||
pub fn unsupported(m: &Manifest, current: &str) -> bool {
|
||||
!m.min_supported_version.is_empty() && newer(&m.min_supported_version, current)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
|
||||
const SAMPLE: &str = r#"{"channel":"devnet","consensus":{"activation_height":120000,"deadline_note":"difficulty v2"},"min_supported_version":"0.3.0","notes":"difficulty v2 at height 120000","platforms":{"mac":{"kind":"dmg","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":20588331,"url":"https://dl.igneum.network/dl/t/Igneum-Miner-0.3.1.dmg"},"windows":{"kind":"inno-setup","sha256":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb","size":43978429,"url":"https://dl.igneum.network/dl/t/Igneum-Miner-Setup-0.3.1.exe"}},"published_at":"2026-10-04T13:00:00Z","version":"0.3.1"}"#;
|
||||
|
||||
fn key() -> (SigningKey, String) {
|
||||
let sk = SigningKey::from_bytes(&[7u8; 32]);
|
||||
let pk = hex_encode(sk.verifying_key().as_bytes());
|
||||
(sk, pk)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_manifest() {
|
||||
let m = parse(SAMPLE).unwrap();
|
||||
assert_eq!(m.version, "0.3.1");
|
||||
assert_eq!(m.channel, "devnet");
|
||||
assert_eq!(m.activation_height, Some(120000));
|
||||
assert_eq!(m.deadline_note, "difficulty v2");
|
||||
assert_eq!(m.min_supported_version, "0.3.0");
|
||||
let mac = m.mac.as_ref().unwrap();
|
||||
assert_eq!(mac.kind, "dmg");
|
||||
assert_eq!(mac.size, 20588331);
|
||||
assert_eq!(m.windows.as_ref().unwrap().kind, "inno-setup");
|
||||
assert_eq!(m.platform("linux"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_platform_is_none_and_bad_entries_fail() {
|
||||
let m = parse(r#"{"version":"0.3.1","platforms":{"mac":null},"consensus":{"activation_height":null}}"#).unwrap();
|
||||
assert!(m.mac.is_none() && m.windows.is_none());
|
||||
assert_eq!(m.activation_height, None);
|
||||
assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"http://x","sha256":"aa","size":1,"kind":"dmg"}}}"#).unwrap_err().contains("https"));
|
||||
assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"https://x","sha256":"aa","size":1,"kind":"dmg"}}}"#).unwrap_err().contains("sha256"));
|
||||
assert!(parse(r#"{"version":"0.3.1","platforms":{"mac":{"url":"https://x","sha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size":1,"kind":"tar"}}}"#).unwrap_err().contains("kind"));
|
||||
assert!(parse(r#"{"version":"latest"}"#).is_err());
|
||||
assert!(parse("not json").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signature_verifies_and_tampering_fails() {
|
||||
let (sk, pk) = key();
|
||||
let sig = hex_encode(&sk.sign(SAMPLE.as_bytes()).to_bytes());
|
||||
assert!(verify_signature(SAMPLE.as_bytes(), &sig, &pk).is_ok());
|
||||
let m = verify_and_parse(SAMPLE.as_bytes(), &sig, &pk).unwrap();
|
||||
assert_eq!(m.version, "0.3.1");
|
||||
// a tampered sha256 inside the manifest: the bytes changed, the signature no longer verifies
|
||||
let tampered = SAMPLE.replace("aaaaaaaa", "aaaaaaab");
|
||||
assert!(verify_and_parse(tampered.as_bytes(), &sig, &pk).is_err());
|
||||
// a bad signature
|
||||
let mut bad = sig.clone();
|
||||
bad.replace_range(0..2, if &sig[0..2] == "00" { "01" } else { "00" });
|
||||
assert!(verify_signature(SAMPLE.as_bytes(), &bad, &pk).is_err());
|
||||
// another key
|
||||
let other = hex_encode(SigningKey::from_bytes(&[9u8; 32]).verifying_key().as_bytes());
|
||||
assert!(verify_signature(SAMPLE.as_bytes(), &sig, &other).is_err());
|
||||
// garbage
|
||||
assert!(verify_signature(SAMPLE.as_bytes(), "zz", &pk).is_err());
|
||||
assert!(verify_signature(SAMPLE.as_bytes(), &sig, "abcd").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sha256_of_file_is_checked_by_the_caller() {
|
||||
let dir = std::env::temp_dir().join(format!("igneum-manifest-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = dir.join("x.bin");
|
||||
std::fs::write(&f, b"abc").unwrap();
|
||||
assert_eq!(sha256_file(&f).unwrap(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
std::fs::write(&f, b"abd").unwrap();
|
||||
assert_ne!(sha256_file(&f).unwrap(), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versions() {
|
||||
assert!(newer("0.3.1", "0.3.0"));
|
||||
assert!(newer("0.4.0", "0.3.9"));
|
||||
assert!(newer("1.0.0", "0.99.99"));
|
||||
assert!(newer("v0.3.1", "0.3.0"));
|
||||
assert!(!newer("0.3.0", "0.3.0"));
|
||||
assert!(!newer("0.2.9", "0.3.0"));
|
||||
assert!(!newer("0.3", "0.3.0"));
|
||||
assert!(newer("0.3.1", "0.3"));
|
||||
assert!(newer("0.3.1", "0.3.1-rc1"));
|
||||
assert!(!newer("0.3.1-rc1", "0.3.1"));
|
||||
assert!(newer("0.3.1-rc2", "0.3.1-rc1"));
|
||||
assert!(!newer("", "0.3.0"));
|
||||
assert!(!newer("latest", "0.3.0"));
|
||||
assert!(!newer("0.3.1", "garbage"));
|
||||
assert!(!newer("0.3.1-", "0.3.0"));
|
||||
assert!(!newer("0.3.1.2", "0.3.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn safe_moments() {
|
||||
let base = Moment { node_synced: true, boundary_eta_s: Some(1800), miner_busy: false, ready_for_s: 60, urgent: false };
|
||||
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!(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(0), ..base.clone() }).is_err());
|
||||
assert!(safe_to_apply(&Moment { boundary_eta_s: Some(181), ..base.clone() }).is_ok());
|
||||
assert!(safe_to_apply(&Moment { boundary_eta_s: None, ..base.clone() }).is_ok());
|
||||
assert!(safe_to_apply(&Moment { miner_busy: true, ..base.clone() }).unwrap_err().contains("worker"));
|
||||
// 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());
|
||||
// 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 - 1, ..base.clone() }).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_closeness_and_support() {
|
||||
assert!(!fork_is_close(None, 100_000));
|
||||
assert!(!fork_is_close(Some(120_000), 0));
|
||||
assert!(!fork_is_close(Some(120_000), 118_199));
|
||||
assert!(fork_is_close(Some(120_000), 118_200));
|
||||
assert!(fork_is_close(Some(120_000), 120_000));
|
||||
assert!(fork_is_close(Some(120_000), 130_000));
|
||||
let m = parse(SAMPLE).unwrap();
|
||||
assert!(!unsupported(&m, "0.3.0"));
|
||||
assert!(unsupported(&m, "0.2.9"));
|
||||
assert!(!unsupported(&parse(r#"{"version":"0.3.1"}"#).unwrap(), "0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_is_sha256_of_key_bytes() {
|
||||
let (_, pk) = key();
|
||||
assert_eq!(fingerprint(&pk).len(), 64);
|
||||
assert_eq!(fingerprint("zz"), "");
|
||||
}
|
||||
}
|
||||
1
app/igneum-app/src/ota.rs
Normal file
1
app/igneum-app/src/ota.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
//! placeholder
|
||||
|
|
@ -272,7 +272,10 @@
|
|||
$('d-blocks-sub').textContent = m.accepted_session + ' this run' + (m.found.length ? ' · ' + m.found.length + ' last hour' : '') + (m.rejected_session ? ' · ' + m.rejected_session + ' rejected' : '');
|
||||
var nodeWord = n.state === 'synced' ? 'synced' : n.state === 'syncing' ? 'syncing' : n.state;
|
||||
$('d-node').textContent = nodeWord;
|
||||
$('d-node-sub').textContent = (s.clock && s.clock.severity === 'block') ? 'clock ' + Math.round(Math.abs(s.clock.skew_s)) + ' s ' + (s.clock.skew_s < 0 ? 'behind' : 'ahead') + ': fix it to mine' : n.state === 'synced' ? ('height ' + withCommas(n.blocks) + ', ' + n.peers + ' peer' + (n.peers === 1 ? '' : 's')) : n.state === 'syncing' ? (n.message || (withCommas(n.blocks) + ' blocks')) : n.state === 'restarting' ? ('restart in ' + n.restart_in_s + ' s') : (n.message || '');
|
||||
// the live numbers always show, so a stuck label is visible as stuck
|
||||
var live = 'height ' + withCommas(n.blocks) + (n.headers > n.blocks ? ' of ' + withCommas(n.headers) : '') + ' · daa ' + withCommas(n.daa) + ' · ' + n.peers + ' peer' + (n.peers === 1 ? '' : 's');
|
||||
$('d-node-sub').textContent = (s.clock && s.clock.severity === 'block') ? 'clock ' + Math.round(Math.abs(s.clock.skew_s)) + ' s ' + (s.clock.skew_s < 0 ? 'behind' : 'ahead') + ': fix it to mine' : n.state === 'restarting' ? ('restart in ' + n.restart_in_s + ' s · ' + live) : (n.state === 'syncing' && n.message ? n.message + ' · ' : '') + live;
|
||||
$('d-node-sub').title = n.last_reading_age_s >= 0 ? 'last reading ' + Math.round(n.last_reading_age_s) + ' s ago' : '';
|
||||
var cell = $('d-node-cell'); cell.classList.toggle('ok', n.state === 'synced'); cell.classList.toggle('bad', n.state === 'failed' || n.state === 'restarting' || n.state === 'stopped' || (s.clock && s.clock.severity === 'block'));
|
||||
if (n.daa > 0 && p.eta_s >= 0) { $('d-eta').textContent = hms(p.eta_s); $('d-eta-sub').textContent = p.message + (p.epoch_index ? ' (epoch ' + p.epoch_index + ')' : ''); }
|
||||
else { $('d-eta').textContent = '--:--'; $('d-eta-sub').textContent = 'waiting for the node'; }
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 278 KiB After Width: | Height: | Size: 280 KiB |
Loading…
Reference in a new issue