Igneum Miner: clock-skew detection (the node's too-far-in-the-future refusals, the median of local minus block time over the EVM RPC, an HTTPS Date header), warn over 5 s, block Start and hold the miners over 10 s, one-click Sync clock per platform; bench-log entry and the node-change note (a 60 s slow node is silently dead)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-04 11:23:56 +00:00
parent 558e02e372
commit ad3fb1c39f
13 changed files with 457 additions and 6 deletions

View file

@ -169,6 +169,10 @@ impl Runtime {
pub fn id8(&self) -> String {
self.machine_id.chars().take(8).collect()
}
/// The node's Ethereum JSON-RPC port: the default 26790 sits 180 above the default RPC port, kept for any port.
pub fn evm_port(&self) -> u16 {
self.rpc_port.wrapping_add(180)
}
pub fn rpc_url(&self) -> String {
format!("grpc://127.0.0.1:{}", self.rpc_port)
}

View file

@ -36,6 +36,13 @@ pub enum Cmd {
CheckUpdate,
Updated(crate::state::UpdateState),
WorkerBuilt(usize, Result<PathBuf, String>),
/// local minus the latest block's timestamp, seconds (from the node's EVM RPC)
ClockSample(f64),
/// local minus an HTTPS Date header, seconds; None when the check failed
ClockHttps(Option<f64>),
ClockCheck,
ClockSync,
ClockSynced(Result<String, String>),
Quit,
}
@ -75,6 +82,8 @@ impl Shared {
st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on() };
st.live_page = packaged.live_page.clone();
st.finality.message = "waiting for the miner".into();
st.clock.hint = crate::platform::clock_hint().into();
st.clock.severity = "none".into();
st.program.message = "waiting for the node".into();
Shared {
token,
@ -330,6 +339,12 @@ pub struct Engine {
wrapper: bool,
last_state_print: Instant,
detected: bool,
clock_samples: Vec<f64>,
clock_node_at: Option<Instant>,
clock_node_behind: f64,
clock_https: Option<(f64, Instant)>,
clock_next_https: Instant,
clock_last_sample: Instant,
last_settings_save: Instant,
last_error_event: Instant,
}
@ -381,6 +396,12 @@ impl Engine {
wrapper,
last_state_print: now,
detected: false,
clock_samples: Vec::new(),
clock_node_at: None,
clock_node_behind: 0.0,
clock_https: None,
clock_next_https: now + Duration::from_secs(5),
clock_last_sample: now,
last_settings_save: now,
last_error_event: now - Duration::from_secs(600),
}
@ -542,6 +563,65 @@ impl Engine {
}
}
}
Cmd::ClockSample(d) => {
self.clock_samples.push(d);
if self.clock_samples.len() > 9 {
self.clock_samples.remove(0);
}
self.resolve_clock();
}
Cmd::ClockHttps(r) => {
if let Some(d) = r {
self.clock_https = Some((d, Instant::now()));
self.shared.log(&format!("clock check (https): local time is {:+.1} s against dl.igneum.network", d));
} else {
self.shared.log("clock check (https): no answer from dl.igneum.network");
}
self.resolve_clock();
}
Cmd::ClockCheck => {
self.clock_next_https = Instant::now() + Duration::from_secs(600);
if let Some(f) = std::env::var("IGNEUM_APP_FAKE_SKEW").ok().and_then(|v| v.parse::<f64>().ok()) {
self.command(Cmd::ClockHttps(Some(f)));
return;
}
let shared = self.shared.clone();
std::thread::spawn(move || {
let r = crate::update::https_time("https://dl.igneum.network/").map(|t| crate::platform::unix_now_f() - t);
shared.send(Cmd::ClockHttps(r));
});
}
Cmd::ClockSync => {
if self.st().clock.syncing {
return;
}
self.st().clock.syncing = true;
self.shared.event("info", "asking the system to set its clock from a time server");
let shared = self.shared.clone();
std::thread::spawn(move || {
let r = crate::platform::sync_clock();
shared.send(Cmd::ClockSynced(r));
});
}
Cmd::ClockSynced(r) => {
let mut st = self.st();
st.clock.syncing = false;
match &r {
Ok(t) => st.clock.sync_result = t.clone(),
Err(e) => st.clock.sync_result = format!("could not set the clock: {e}"),
}
drop(st);
match r {
Ok(t) => self.shared.event("ok", &format!("clock: {t}; checking again")),
Err(e) => self.shared.event("error", &format!("clock: {e}")),
}
// the sources answer again: forget the old samples and re-check
self.clock_samples.clear();
self.clock_node_at = None;
self.clock_https = None;
self.resolve_clock();
self.clock_next_https = Instant::now() + Duration::from_secs(6);
}
Cmd::Quit => {
self.quitting = true;
self.st().quitting = true;
@ -591,6 +671,7 @@ impl Engine {
format!("--{}", r.network),
format!("--appdir={}", r.node_dir.display()),
format!("--rpclisten=127.0.0.1:{}", r.rpc_port),
format!("--evm-rpclisten=127.0.0.1:{}", r.evm_port()),
format!("--listen=0.0.0.0:{}", r.p2p_port),
];
if let Some(n) = r.devnet_suffix {
@ -922,6 +1003,14 @@ impl Engine {
if now >= self.update_next {
self.command(Cmd::CheckUpdate);
}
if now >= self.clock_next_https {
self.command(Cmd::ClockCheck);
}
if self.clock_node_at.map(|t| now.duration_since(t) > Duration::from_secs(90)).unwrap_or(false) {
// the node stopped complaining: the clock (or the peers) changed
self.clock_node_at = None;
self.resolve_clock();
}
if self.wrapper && now.duration_since(self.last_state_print) >= Duration::from_secs(3) {
self.last_state_print = now;
println!("STATE {}", self.shared.wrapper_state());
@ -1013,7 +1102,8 @@ impl Engine {
}
fn tick_miners(&mut self, now: Instant) {
let synced = self.st().node.synced;
// a clock over the consensus bound: the node cannot sync and a miner would only submit rejected blocks
let synced = self.st().node.synced && self.st().clock.severity != "block";
let paused = self.st().mining.paused;
for i in 0..self.miners.len() {
let card_idx = self.miners[i].card;
@ -1150,7 +1240,102 @@ impl Engine {
}
}
Source::Miner(card) => self.miner_line(card, l.stderr, &l.text),
Source::Node => {}
Source::Node => self.node_line(&l.text),
}
}
/// igneumd's own lines. One matters for the user: relayed blocks refused as "too far into the future", which is
/// this machine's clock running behind the network (PC 2, 4 October 2026: 60 s slow after a power cut, 0 blocks).
fn node_line(&mut self, text: &str) {
if !text.contains("too far into the future") {
return;
}
if let Some(behind) = behind_from_warning(text) {
self.clock_node_behind = behind;
}
let first = self.clock_node_at.is_none();
self.clock_node_at = Some(Instant::now());
if first {
self.shared.log(&format!("node: relayed blocks refused as too far in the future: this clock is at least {:.0} s behind the network", self.clock_node_behind));
}
self.resolve_clock();
}
/// The worst of the three clock sources decides. skew = local minus network; behind = negative.
fn resolve_clock(&mut self) {
let now = Instant::now();
let mut skew: Option<f64> = None;
let mut source = "";
// 1. the node's own refusal: a lower bound on how far behind we are
if let Some(t) = self.clock_node_at {
if now.duration_since(t) <= Duration::from_secs(90) {
skew = Some(-self.clock_node_behind.max(10.0));
source = "node";
}
}
// 2. the median of local-minus-block-time over the last samples: behind only (a stalled chain reads as ahead)
if self.clock_samples.len() >= 3 {
let mut v = self.clock_samples.clone();
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
let median = v[v.len() / 2];
if median < -5.0 && skew.map(|s| median < s).unwrap_or(true) {
skew = Some(median);
source = "blocks";
}
}
// 3. the HTTPS Date header: either direction, 1 s resolution plus the request latency
if let Some((d, t)) = self.clock_https {
if now.duration_since(t) <= Duration::from_secs(1200) && d.abs() > 5.0 && skew.map(|s| d.abs() > s.abs()).unwrap_or(true) {
skew = Some(d);
source = "https";
}
}
let mut st = self.st();
let was = st.clock.severity.clone();
st.clock.checked_at = crate::platform::unix_now_f();
match skew {
Some(d) if d.abs() > 10.0 => {
st.clock.severity = "block".into();
st.clock.skew_s = d;
st.clock.source = source.into();
let n = d.abs().round() as i64;
st.clock.message = if d < 0.0 {
format!("Your clock is about {n} seconds behind the network{}; mining cannot start until it is fixed.", if source == "node" { " (at least)" } else { "" })
} else {
format!("Your clock is about {n} seconds ahead of the network; mining cannot start until it is fixed.")
};
}
Some(d) => {
st.clock.severity = "warn".into();
st.clock.skew_s = d;
st.clock.source = source.into();
let n = d.abs().round() as i64;
st.clock.message = format!("Your clock is about {n} seconds {} the network. Over 10 s the node refuses blocks.", if d < 0.0 { "behind" } else { "ahead of" });
}
None => {
st.clock.severity = "none".into();
st.clock.skew_s = 0.0;
st.clock.source = String::new();
st.clock.message = String::new();
}
}
let is = st.clock.severity.clone();
let msg = st.clock.message.clone();
drop(st);
if is != was {
match is.as_str() {
"block" => {
self.shared.event("error", &msg);
if self.miners.iter().any(|m| m.proc.is_some()) {
self.stop_miners("clock over the consensus bound");
for m in self.miners.iter_mut() {
m.restart_at = Some(Instant::now());
}
}
}
"warn" => self.shared.event("info", &msg),
_ => self.shared.event("ok", "clock agrees with the network again"),
}
}
}
@ -1181,6 +1366,17 @@ impl Engine {
false
};
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) {
self.clock_last_sample = now;
let port = self.shared.runtime.evm_port();
let shared = self.shared.clone();
std::thread::spawn(move || {
if let Some(t) = crate::update::latest_block_time(port) {
shared.send(Cmd::ClockSample(crate::platform::unix_now_f() - t));
}
});
}
{
let mut st = self.st();
st.node.blocks = blocks;
@ -1433,6 +1629,26 @@ impl Engine {
}
}
/// "...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();
if nums.len() < 2 {
return None;
}
let behind = (nums[nums.len() - 2] - nums[nums.len() - 1]) / 1000.0;
if behind > 0.0 { Some(behind) } else { None }
}
#[cfg(test)]
mod tests {
#[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";
assert_eq!(super::behind_from_warning(l), Some(63.0));
assert_eq!(super::behind_from_warning("no numbers here"), None);
}
}
fn short(s: &str, n: usize) -> String {
if s.chars().count() <= n { s.to_string() } else { format!("{}...", s.chars().take(n).collect::<String>()) }
}

View file

@ -284,6 +284,66 @@ pub fn clear_quarantine() {
}
}
/// The manual instruction for fixing the clock on this platform.
pub fn clock_hint() -> &'static str {
#[cfg(target_os = "macos")]
{
"System Settings > General > Date & Time: turn on \"Set time and date automatically\", or run: sudo sntp -sS time.apple.com"
}
#[cfg(windows)]
{
"Settings > Time & language > Date & time: turn on \"Set time automatically\" and click \"Sync now\", or run as administrator: w32tm /resync"
}
#[cfg(not(any(target_os = "macos", windows)))]
{
"run: sudo chronyc makestep, or sudo timedatectl set-ntp true"
}
}
/// Asks the operating system to set the clock from a time server (an administrator prompt appears). Returns what
/// happened, for the window. Blocking; call from a thread.
pub fn sync_clock() -> Result<String, String> {
#[cfg(target_os = "macos")]
{
let out = Command::new("osascript")
.args(["-e", "do shell script \"/usr/bin/sntp -sS time.apple.com 2>&1\" with administrator privileges"])
.output()
.map_err(|e| e.to_string())?;
let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr));
if out.status.success() {
Ok(if text.trim().is_empty() { "clock set from time.apple.com".into() } else { text.trim().to_string() })
} else if text.contains("canceled") || text.contains("cancelled") {
Err("the administrator prompt was cancelled".into())
} else {
Err(text.trim().to_string())
}
}
#[cfg(windows)]
{
let script = "Start-Process -FilePath cmd.exe -ArgumentList '/c net start w32time & w32tm /resync /force' -Verb RunAs -Wait -WindowStyle Hidden";
let mut c = Command::new("powershell");
c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script]);
quiet(&mut c);
let out = c.output().map_err(|e| e.to_string())?;
if out.status.success() {
Ok("asked Windows Time to resync (w32tm /resync)".into())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
#[cfg(not(any(target_os = "macos", windows)))]
{
for args in [vec!["chronyc", "makestep"], vec!["timedatectl", "set-ntp", "true"]] {
if let Ok(out) = Command::new("pkexec").args(&args).output() {
if out.status.success() {
return Ok(format!("ran {}", args.join(" ")));
}
}
}
Err("neither chronyc nor timedatectl could set the clock".into())
}
}
/// Builds a command that runs without a console window on Windows.
pub fn quiet(cmd: &mut Command) -> &mut Command {
#[cfg(windows)]

View file

@ -230,6 +230,14 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
Err("only http(s) links open".into())
}
}
"/api/clock/sync" => {
shared.send(Cmd::ClockSync);
Ok(json!({ "ok": true }))
}
"/api/clock/check" => {
shared.send(Cmd::ClockCheck);
Ok(json!({ "ok": true }))
}
"/api/quit" => {
shared.send(Cmd::Quit);
Ok(json!({ "ok": true }))

View file

@ -87,6 +87,19 @@ pub struct FinalityState {
pub message: String,
}
/// Clock skew against the network. skew_s = local time minus network time (negative = this machine is behind).
#[derive(Clone, Serialize, Default)]
pub struct ClockState {
pub skew_s: f64,
pub severity: String, // none | warn | block
pub source: String, // node | blocks | https
pub message: String,
pub hint: String, // the manual instruction for this platform
pub checked_at: f64,
pub syncing: bool,
pub sync_result: String,
}
#[derive(Clone, Serialize, Default)]
pub struct AddressState {
pub value: String,
@ -147,6 +160,7 @@ pub struct State {
pub mining: MiningState,
pub program: ProgramState,
pub finality: FinalityState,
pub clock: ClockState,
pub address: AddressState,
pub settings: SettingsState,
pub update: UpdateState,

View file

@ -51,6 +51,64 @@ pub fn newer(latest: &str, current: &str) -> bool {
false
}
/// The network's idea of now from an HTTPS Date header (1 s resolution), as unix seconds. The second clock source,
/// independent of the node.
pub fn https_time(url: &str) -> Option<f64> {
let out = crate::detect::run_timeout(Command::new("curl").args(["-sI", "--max-time", "10", url]), None, Duration::from_secs(12))?;
let line = out.lines().find(|l| l.to_ascii_lowercase().starts_with("date:"))?;
parse_http_date(line[5..].trim())
}
/// "Sun, 04 Oct 2026 11:17:47 GMT" -> unix seconds.
pub fn parse_http_date(s: &str) -> Option<f64> {
let parts: Vec<&str> = s.split_whitespace().collect();
if parts.len() < 5 {
return None;
}
let day: i64 = parts[1].parse().ok()?;
let month = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"].iter().position(|m| parts[2].to_ascii_lowercase().starts_with(m))? as i64 + 1;
let year: i64 = parts[3].parse().ok()?;
let hms: Vec<i64> = parts[4].split(':').filter_map(|p| p.parse().ok()).collect();
if hms.len() != 3 {
return None;
}
let days = days_from_civil(year, month, day);
Some((days * 86400 + hms[0] * 3600 + hms[1] * 60 + hms[2]) as f64)
}
fn days_from_civil(y: i64, m: i64, d: i64) -> i64 {
let y = if m <= 2 { y - 1 } else { y };
let era = if y >= 0 { y } else { y - 399 } / 400;
let yoe = y - era * 400;
let mp = (m + 9) % 12;
let doy = (153 * mp + 2) / 5 + d - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
era * 146_097 + doe - 719_468
}
/// 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.
pub fn latest_block_time(evm_port: u16) -> Option<f64> {
let body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}";
let out = crate::detect::run_timeout(
Command::new("curl").args(["-s", "--max-time", "5", "-X", "POST", &format!("http://127.0.0.1:{evm_port}"), "-H", "Content-Type: application/json", "-d", body]),
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)
}
#[cfg(test)]
mod tests {
#[test]
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("Thu, 01 Jan 1970 00:00:00 GMT"), Some(0.0));
}
}
/// Posts the last 256 KB of a log file as {label, machine, run_id, lines}, as site/api/log.mjs expects.
pub fn upload_log(url: &str, key: &str, label: &str, machine: &str, run_id: &str, path: &std::path::Path) -> bool {
if url.is_empty() || key.is_empty() {

View file

@ -67,6 +67,14 @@ body.mac .top{padding-left:92px}
/* update banner */
.banner{position:fixed;top:var(--top);left:0;right:0;z-index:19;display:flex;align-items:center;justify-content:center;gap:14px;padding:10px var(--gutter);background:rgba(242,84,27,.12);border-bottom:1px solid rgba(242,84,27,.4);font-size:14px}
.banner.clock{background:rgba(242,84,27,.14);border-bottom-color:rgba(242,84,27,.5);flex-wrap:wrap}
.banner.clock.warn{background:rgba(255,179,92,.1);border-bottom-color:rgba(255,179,92,.4)}
.banner .hint{font-size:11px;color:var(--ash);flex-basis:100%;text-align:center}
.clock-card{margin-top:12px;border:1px solid rgba(242,84,27,.5);background:rgba(242,84,27,.08);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:8px}
.clock-card.warn{border-color:rgba(255,179,92,.4);background:rgba(255,179,92,.06)}
.clock-msg{font-size:14px;color:var(--bone);line-height:1.45}
.clock-card .note{margin-top:0}
/* screens */
main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter)}
body.has-bottom main{bottom:var(--bottom)}

View file

@ -129,7 +129,7 @@
$('s-update').addEventListener('click', function () { api('api/update/check', {}); $('s-update-note').textContent = 'Checking.'; setTimeout(fillSettings, 4000); });
$('s-live').addEventListener('click', function () { if (state && state.live_page) api('api/open', { url: state.live_page }); });
$('update-get').addEventListener('click', function () { if (state && state.update.url) api('api/open', { url: state.update.url }); });
$('update-later').addEventListener('click', function () { $('update-banner').hidden = true; $('update-banner').dataset.dismissed = '1'; });
$('update-later').addEventListener('click', function () { $('update-banner').hidden = true; $('update-banner').dataset.dismissed = '1'; layoutBanners(); });
// ---------- bottom bar ----------
$('btn-pause').addEventListener('click', function () {
@ -272,8 +272,8 @@
$('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 = 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 || '');
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');
$('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 || '');
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'; }
var nowU = s.now;
@ -329,10 +329,41 @@
else text = s.mining.state;
$('pill-text').textContent = text;
}
function renderClock(s) {
var c = s.clock || { severity: 'none' }, bad = c.severity === 'block', warn = c.severity === 'warn';
var banner = $('clock-banner');
banner.hidden = !(bad || warn) || phase === 'dashboard';
banner.classList.toggle('warn', warn);
$('clock-banner-text').textContent = c.message || '';
$('clock-banner-hint').textContent = c.hint || '';
$('clock-sync').disabled = !!c.syncing; $('clock-sync').textContent = c.syncing ? 'Syncing' : 'Sync clock';
var card = $('n-clock');
card.hidden = !(bad || warn);
card.classList.toggle('warn', warn);
$('n-clock-msg').textContent = c.message || '';
$('n-clock-hint').textContent = c.hint ? 'By hand: ' + c.hint : '';
$('n-clock-result').textContent = c.sync_result || '';
$('n-clock-sync').disabled = !!c.syncing; $('n-clock-sync').textContent = c.syncing ? 'Syncing' : 'Sync clock';
$('btn-start').disabled = bad;
$('start-blocked').hidden = !bad;
$('start-blocked').textContent = bad ? c.message + ' Use "Sync clock" above.' : '';
$('btn-key-done').disabled = bad || !$('key-ack').checked;
layoutBanners();
}
// fixed banners push the content down by their height
function layoutBanners() {
var h = 0;
['clock-banner', 'update-banner'].forEach(function (id) { var b = $(id); if (!b.hidden) { b.style.top = (60 + h) + 'px'; h += b.offsetHeight; } });
$('main').style.top = (60 + h) + 'px';
}
window.addEventListener('resize', layoutBanners);
$('clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); });
$('n-clock-sync').addEventListener('click', function () { api('api/clock/sync', {}); });
function render(s) {
state = s; stateAt = performance.now();
renderPill(s);
if (s.update.available && !$('update-banner').dataset.dismissed) { $('update-version').textContent = 'Igneum Miner ' + s.update.version; $('update-banner').hidden = false; }
renderClock(s);
if (s.update.available && !$('update-banner').dataset.dismissed) { $('update-version').textContent = 'Igneum Miner ' + s.update.version; $('update-banner').hidden = false; layoutBanners(); }
if (phase === 'cards') renderCards(s);
if (phase === 'dashboard') renderDashboard(s);
if (s.quitting && !$('btn-quit').disabled) { $('btn-quit').disabled = true; }

View file

@ -23,6 +23,11 @@
</div>
</header>
<div class="banner clock" id="clock-banner" hidden>
<span id="clock-banner-text"></span>
<button class="btn small primary" id="clock-sync">Sync clock</button>
<span class="hint mono" id="clock-banner-hint"></span>
</div>
<div class="banner" id="update-banner" hidden>
<span><b id="update-version">A new version</b> of Igneum Miner is ready.</span>
<button class="btn small primary" id="update-get">Download</button>
@ -112,6 +117,7 @@
</span>
</label>
</div>
<p class="err" id="start-blocked" hidden></p>
<div class="cta">
<button class="btn primary big" id="btn-start">Start mining</button>
<button class="btn ghost" id="btn-address-back">Back</button>
@ -171,6 +177,11 @@
<div><span class="k">difficulty</span><span class="v mono" id="n-diff">0</span></div>
<div><span class="k">tips</span><span class="v mono" id="n-tips">0</span></div>
</div>
<div class="clock-card" id="n-clock" hidden>
<p class="clock-msg" id="n-clock-msg"></p>
<div class="row"><button class="btn small primary" id="n-clock-sync">Sync clock</button><span class="note mono small" id="n-clock-result"></span></div>
<p class="note" id="n-clock-hint"></p>
</div>
<p class="note" id="n-note"></p>
</div>
<div class="card">

View file

@ -752,3 +752,21 @@ jobs and exits 3 on any of these; the miner kills and restarts a worker whose jo
the mean or whose interval rate exceeds 10x the mean before it, rolls its counters back to the last report and prints
`WORKER FAULT`; the launcher shows `worker fault` and `restarting` for that card instead of a rate. The next gfx1036
run says which guard fires first; that line is the diagnosis the Mac cannot give.
## 4 October 2026, a node 60 s behind the clock is silently dead (PC 2's first app install)
PC 2 came back from a power cut with its clock 60 s slow. igneumd connected, then logged `HandleRelayInvsFlow flow
error: the block timestamp is too far into the future: block timestamp is ... but maximum timestamp allowed is ...`
for every relayed block, processed 0 blocks and the app sat on "waiting for a peer" with nothing to say. The
consensus rule is right (a header may not be ahead of the node's clock by more than the tolerance,
`check_block_timestamp_in_isolation`); the reporting was not. The node prints one WARN per relayed block with two
millisecond numbers and never the one line a person needs.
| What | Where | Now |
|---|---|---|
| The app reads that WARN, takes `block timestamp - maximum allowed` as a lower bound and shows "Your clock is at least N seconds behind the network; mining cannot start until it is fixed" on the node card with a Sync clock button (macOS `sntp -sS time.apple.com` under an administrator prompt, Windows `w32tm /resync` elevated, Linux `chronyc makestep` / `timedatectl`) and the manual steps | `app/igneum-app/src/engine.rs` `node_line`, `resolve_clock`; `platform.rs` `sync_clock` | done |
| Independent of the node: once blocks arrive the engine samples the latest block's timestamp through the node's Ethereum JSON-RPC every 10 s and takes the median of local minus block time over the last 9 (behind only; a stalled chain reads as ahead); and an HTTPS Date header from dl.igneum.network at start and every 10 min (either direction, 1 s resolution). Over 5 s: a warning. Over 10 s (the consensus bound): the Start button is blocked and running miners are held | `engine.rs` `watch_line`, `update.rs` `latest_block_time`, `https_time` | done |
| The node itself should log one clear line at WARN, once, not per block: `clock skew: local time is N s behind the median peer block time` (and the same for ahead, when its own templates are refused by peers). Filed for the devnet-v4 worktree owner; the app does not patch the node | `docs/plans/node-changes.md` | filed |
Checked on the Mac with a fake 60 s skew (`IGNEUM_APP_FAKE_SKEW=-60`): the banner, the node card, the blocked Start
button and the held miner all showed; with the real clock the HTTPS source read +0.4 s and the block source agreed.

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

View file

@ -0,0 +1,23 @@
# Node changes filed by the app work (for the devnet-v4 worktree owner)
Filed 4 October 2026 from the Igneum Miner app. The app does not patch the node.
## 1. One clear clock-skew line (from PC 2's first install)
A node whose clock is 60 s slow connects, refuses every relayed block with
`HandleRelayInvsFlow flow error: the block timestamp is too far into the future: block timestamp is X but maximum
timestamp allowed is Y` (one WARN per block, two millisecond numbers) and processes nothing. A person reading the log
cannot tell that the clock is the cause.
Wanted: in the relay flow, when a header is refused for being ahead of the local clock, keep a running median of
(header timestamp minus local now) over the refused headers of the last minute and log ONCE per minute at WARN:
clock skew: local time is N s behind the median peer block time (blocks are being refused; set the clock)
and the mirror case (own templates refused by peers as too old, or peers' headers far in the past while the DAA
window is empty): `clock skew: local time is N s ahead of the median peer block time`. Also worth exposing in
`getBlockDagInfo` (or a new RPC field) so launchers and the app can read it without parsing logs.
Where: `vendor/igneum-node-v4`, `protocol/flows/src/v10/blockrelay/flow.rs` (the relay error path) and
`consensus/core/src/errors/block.rs` (the error text). The app's current reading of the error line is in
`app/igneum-app/src/engine.rs` (`node_line`, `behind_from_warning`) and will keep working after the change.