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:
igneum-josh 2026-10-04 12:32:22 +01:00
parent 2309c8d9b0
commit 0d4498ed35
9 changed files with 710 additions and 17 deletions

View file

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

View file

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

View 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);
}
}
}

View file

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

View file

@ -19,6 +19,8 @@ mod platform;
mod procs;
mod server;
mod state;
mod manifest;
mod ota;
mod update;
use std::io::{BufRead, Write};

View 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"), "");
}
}

View file

@ -0,0 +1 @@
//! placeholder

View file

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