From 4f6cfbfba7b2b2db682d9c70e2b8d83777444f4e Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 23:28:24 +0000 Subject: [PATCH] miner-ui-4: fold Ember's finality-paused words, read igneum_getNodeInfo (params, network, powEngine stub fault) View.finalityWords with Ember's test (41 UI tests). extnode reads the node lane's reply shape: params compared value by value, network must match, a stub engine is refused and a fault on the app's own node. 168 box tests green. Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/engine.rs | 12 +++++++++++- app/igneum-app/src/extnode.rs | 33 +++++++++++++++++++++++++++------ app/igneum-app/ui/app.js | 22 +++++++++++++++++----- app/igneum-app/ui/view.test.mjs | 14 ++++++++++++++ docs/plans/miner-ui-4.md | 6 ++++++ 5 files changed, 75 insertions(+), 12 deletions(-) diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 697083d9..8d2c9151 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -950,6 +950,15 @@ impl Engine { } Cmd::ExternalNode(check) => self.external_node_decided(check), Cmd::NodeInfo(info) => { + if info.pow_engine.as_deref() == Some("stub") { + let first = self.st().node.state != "stub"; + let mut st = self.st(); + st.node.state = "stub".into(); + st.node.synced = false; + st.node.message = "this node was built without the mining engine (stub) and refuses every real block".into(); + drop(st); + if first { self.shared.event("error", "the node runs the stub engine, not igneum-pow: it refuses every real block. Reinstall the app (ledger N5)"); } + } let mut st = self.st(); if let Some(d) = info.digest.filter(|d| d.len() >= 16) { if st.node.consensus_digest != d { st.node.consensus_digest = d; } @@ -1450,7 +1459,8 @@ impl Engine { fn external_node_decided(&mut self, check: crate::extnode::Check) { let port = self.shared.runtime.p2p_port; let ours = self.node_override_file().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str::(&t).ok()); - let d = crate::extnode::decide(port, ours.as_ref(), None, &check); + let network = self.shared.runtime.network.clone(); + let d = crate::extnode::decide_on(port, ours.as_ref(), None, Some(network.as_str()), &check); self.node_external = d.use_it; { let mut st = self.st(); diff --git a/app/igneum-app/src/extnode.rs b/app/igneum-app/src/extnode.rs index ff96cb66..19612b1a 100644 --- a/app/igneum-app/src/extnode.rs +++ b/app/igneum-app/src/extnode.rs @@ -18,7 +18,10 @@ pub struct Check { pub digest: Option, pub network: Option, pub version: Option, + /// the node's consensus params as it resolved them (`params` in igneum_getNodeInfo, every field by its override-file name) pub overrides: Option, + /// "igneum-pow" or "stub" (ledger N5: a node built without the mining engine refuses every real block) + pub pow_engine: Option, } /// The decision for the port-collision path. @@ -33,20 +36,30 @@ pub struct Decision { /// Compare the other node with ours: `ours` is the override file the app would start its own node with (the /// `*_activation_daa` and friends), `our_chain` the chain id this app's network uses when known. -pub fn decide(port: u16, ours: Option<&Value>, our_chain: Option, theirs: &Check) -> Decision { +pub fn decide(port: u16, ours: Option<&Value>, our_chain: Option, theirs: &Check) -> Decision { decide_on(port, ours, our_chain, None, theirs) } + +/// `decide` with the network name too (devnet | simnet | testnet): the node lane's rule is that `network` must be equal. +pub fn decide_on(port: u16, ours: Option<&Value>, our_chain: Option, our_network: Option<&str>, theirs: &Check) -> Decision { let mut checked = false; + if let (Some(a), Some(b)) = (our_network.filter(|s| !s.is_empty()), theirs.network.as_deref().filter(|s| !s.is_empty())) { + checked = true; + if a != b { + return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on another network ({b}, ours {a})") }; + } + } if let (Some(a), Some(b)) = (our_chain, theirs.chain_id) { checked = true; if a != b { return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on another network (chain id {b}, ours {a})") }; } } + if theirs.pow_engine.as_deref() == Some("stub") { + return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node built without the mining engine (stub), which refuses every real block") }; + } + // every key the manifest sets, against the node's resolved params (the node lane: compare values, never recompute the hash) if let (Some(o), Some(t)) = (ours.and_then(|v| v.as_object()), theirs.overrides.as_ref().and_then(|v| v.as_object())) { checked = true; for (k, v) in o { - if !k.ends_with("_daa") && !k.starts_with("exec_restart") { - continue; - } if t.get(k) != Some(v) { let theirs_v = t.get(k).map(|x| x.to_string()).unwrap_or_else(|| "none".into()); return Decision { use_it: false, verdict: "mismatch", line: format!("Node not started: port {port} is taken by a node on other rules ({k} {theirs_v}, ours {v})") }; @@ -82,7 +95,8 @@ pub fn probe(evm_port: u16) -> Check { c.digest = info.get("digest").and_then(|v| v.as_str()).map(|s| s.to_string()); c.network = info.get("network").and_then(|v| v.as_str()).map(|s| s.to_string()); c.version = info.get("version").and_then(|v| v.as_str()).map(|s| s.to_string()); - c.overrides = info.get("overrides").cloned().filter(|v| v.is_object()); + c.overrides = info.get("params").cloned().filter(|v| v.is_object()); + c.pow_engine = info.get("powEngine").and_then(|v| v.as_str()).map(|s| s.to_string()); } c } @@ -132,10 +146,17 @@ mod tests { let d = decide(26611, Some(&ours()), None, &Check { overrides: Some(theirs), ..Default::default() }); assert!(!d.use_it); assert_eq!(d.line, "Node not started: port 26611 is taken by a node on other rules (fees_v1_activation_daa 200000, ours 210000)"); + // a stub engine is refused whatever else matches + assert!(!decide(26611, Some(&ours()), None, &Check { overrides: Some(ours()), pow_engine: Some("stub".into()), ..Default::default() }).use_it); // an override we have that the other node lacks is a mismatch too let d = decide(26611, Some(&ours()), None, &Check { overrides: Some(json!({ "difficulty_v2_activation_daa": 33000 })), ..Default::default() }); assert!(!d.use_it); - assert!(d.line.contains("fees_v1_activation_daa none")); + assert!(d.line.contains("exec_restart_hash none"), "{}", d.line); + // the network name (node lane, igneum_getNodeInfo.network) must be equal + let d = decide_on(26611, Some(&ours()), None, Some("testnet"), &Check { network: Some("devnet".into()), overrides: Some(ours()), ..Default::default() }); + assert!(!d.use_it); + assert_eq!(d.line, "Node not started: port 26611 is taken by a node on another network (devnet, ours testnet)"); + assert!(decide_on(26611, Some(&ours()), None, Some("testnet"), &Check { network: Some("testnet".into()), overrides: Some(ours()), ..Default::default() }).use_it); } #[test] diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js index b295d58d..9d6a852e 100644 --- a/app/igneum-app/ui/app.js +++ b/app/igneum-app/ui/app.js @@ -474,6 +474,7 @@ var View = (function () { case 'restarting': return { word: 'restarting', line: 'restart in ' + (n.restart_in_s || 0) + ' s' + (n.message ? ' · ' + n.message : ''), tone: 'bad' }; case 'failed': return { word: 'failed', line: n.message || 'the node could not start', tone: 'bad' }; case 'stopped': return { word: n.source === 'none' ? 'not started' : 'stopped', line: n.message || 'the node is not running', tone: 'bad' }; + case 'stub': return { word: 'stub engine', line: 'This node was built without the mining engine, so it refuses every real block. Reinstall the app.', tone: 'bad' }; } return { word: n.state || 'starting', line: n.message || '', tone: '' }; } @@ -664,6 +665,7 @@ var View = (function () { if (n.state === 'no peers') return { text: 'No peers', tone: 'bad' }; if (n.state === 'failed') return { text: 'Node failed', tone: 'bad' }; if (n.state === 'stopped') return { text: n.source === 'none' ? 'Node not started' : 'Node stopped', tone: 'bad' }; + if (n.state === 'stub') return { text: 'Node fault', tone: 'bad' }; if (m.state === 'waiting') return { text: 'Starting the worker', tone: '' }; if (m.state === 'idle') return { text: 'Not mining', tone: '' }; return { text: cap(m.state || 'starting'), tone: '' }; @@ -750,6 +752,15 @@ var View = (function () { if (src === 'site' || src === 'observer') return 'From Igneum’s observer. Your blocks are ringed.'; return ''; } + // finality (ember-tune 1357d280, folded in for 0.3.17): f = state.finality. While the network's finality is paused + // (under two thirds of the weight signing) the node line's sub-text and the lock row say so, and no surface calls a + // lock "final" (main, Q83/Q84). {paused, age, note} + function finalityWords(f) { + f = f || {}; + if (f.paused) return { paused: true, age: 'paused', note: f.line || 'Finality paused: under two thirds of the weight is signing' }; + if (f.last_lock > 0) return { paused: false, age: rel(f.age_s), note: 'A lock is a point the miners have agreed can never be undone. Checkpoints lock at 2/3 of the voting weight.' }; + return { paused: false, age: 'n/a', note: f.message || 'Locks appear once the miner votes on checkpoints.' }; + } // whose node the app reads (publish 2's read-back, 6 October 2026): n.source app | external | none | "" function nodeSource(n) { n = n || {}; @@ -761,7 +772,7 @@ var View = (function () { // the confirmations' words (in place, never a dialog) function addressAsk(a) { return 'Pay ' + shortHex(a, 6, 4) + ' from the next block? Mining restarts.'; } function trustAsk(on) { return on ? 'Include proofs the node never checked? Devnet only. The node restarts.' : 'Check proofs again? The node restarts.'; } - return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk }; + return { PAGES: PAGES, page: page, withCommas: withCommas, compact: compact, rel: rel, ago: ago, shortHex: shortHex, kindWord: kindWord, shownCards: shownCards, present: present, cardTitle: cardTitle, cardRow: cardRow, rowWord: rowWord, tuneWord: tuneWord, tuneEta: tuneEta, tuningCard: tuningCard, poundsPerDay: poundsPerDay, money: money, effOf: effOf, effText: effText, wattsTotal: wattsTotal, noReading: noReading, cells: cells, nextCheck: nextCheck, tunePoint: tunePoint, tunable: tunable, tuneWords: tuneWords, schedule: schedule, GOALS: GOALS, GOAL_NAMES: GOAL_NAMES, goalWords: goalWords, ign: ign, balanceWords: balanceWords, fleetWatts: fleetWatts, fleetPounds: fleetPounds, isMine: isMine, liveNote: liveNote, toggle: toggle, nodeWords: nodeWords, nodeLine: nodeLine, skewWord: skewWord, peersLine: peersLine, heightLine: heightLine, tipAge: tipAge, nodeSource: nodeSource, finalityWords: finalityWords, nextSwitch: nextSwitch, switchLine: switchLine, proveWords: proveWords, verifierWords: verifierWords, proveTier: proveTier, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk }; })(); /* ---------- Ember Tune: the card row's tuning line (pure; tune-line.test.mjs loads this block) ---------- One line per card from the card state (src/state.rs, src/ember.rs): running (the phase and the step), tuned @@ -1602,8 +1613,8 @@ if (typeof document !== 'undefined') (function () { var switchesSig = ''; function renderNode(s) { var n = s.node, f = s.finality, p = s.program, eta = syncEta(n, s.now); - var nl = View.nodeLine(n, s.clock, eta); - setText('node-line-text', nl.text); setText('node-sub', nl.sub); + var nl = View.nodeLine(n, s.clock, eta), fw = View.finalityWords(f); + setText('node-line-text', nl.text); setText('node-sub', fw.paused && nl.tone !== 'bad' ? fw.note : nl.sub); $('node-dot').className = 'dot' + (nl.tone === 'ok' ? ' live' : nl.tone === 'bad' ? ' bad' : ''); $('node-card').classList.toggle('bad', nl.tone === 'bad'); setText('n-state-v', nl.text.replace(/^Node /, '')); setText('n-state-m', nl.sub + (n.override_restart_wait ? ' ' + n.override_restart_wait + '.' : '')); @@ -1624,8 +1635,9 @@ if (typeof document !== 'undefined') (function () { switchesSig = sig; $('n-switches').innerHTML = (n.consensus_switches || []).map(function (x) { return '' + esc(x.name) + (x.daa <= n.daa ? ' (applied)' : '') + '' + withCommas(x.daa) + ''; }).join(''); } - if (f.last_lock > 0) { setText('f-lock', '#' + withCommas(f.last_lock) + ' · ' + rel(f.age_s)); setText('f-note', 'a point the miners agreed can never be undone. It locks at two thirds of the last 30 days of mining'); } - else { setText('f-lock', 'none yet'); setText('f-note', f.message || 'checkpoints lock once this machine and the others vote'); } + setText('f-lock', (f.last_lock > 0 ? '#' + withCommas(f.last_lock) : 'none yet') + ' · ' + fw.age); + setText('f-note', fw.paused ? fw.note : f.last_lock > 0 ? 'a point the miners agreed can never be undone. It locks at two thirds of the last 30 days of mining' : (f.message || 'checkpoints lock once this machine and the others vote')); + $('f-lock').classList.toggle('warn', !!fw.paused); setText('f-votes', withCommas(f.votes)); } diff --git a/app/igneum-app/ui/view.test.mjs b/app/igneum-app/ui/view.test.mjs index ce2a3710..783e1723 100644 --- a/app/igneum-app/ui/view.test.mjs +++ b/app/igneum-app/ui/view.test.mjs @@ -432,6 +432,20 @@ test('N4 (6 October 2026): a frozen tip reads behind, never synced; the tip age assert.equal(V.toggle({ state: 'waiting', paused: false, cards: [card({ state: 'waiting' })] }, { synced: false, state: 'behind' }, {}).sub, 'waiting: the node is behind the chain'); }); +test('finality paused (ember-tune 1357d280): the words, no lock called final while paused', () => { + const p = V.finalityWords({ paused: true, last_lock: 412, age_s: 1200, line: 'Finality paused since 18:39 UTC: under two thirds of the weight is signing' }); + assert.equal(p.paused, true); assert.equal(p.age, 'paused'); assert.equal(p.note, 'Finality paused since 18:39 UTC: under two thirds of the weight is signing'); + assert.equal(/\bfinal\b/i.test(p.note), false); + assert.equal(V.finalityWords({ paused: true, last_lock: 0, age_s: 0 }).note, 'Finality paused: under two thirds of the weight is signing'); + const ok = V.finalityWords({ paused: false, last_lock: 412, age_s: 90 }); + assert.equal(ok.paused, false); assert.equal(ok.age, '1 min ago'); assert.match(ok.note, /never be undone/); + const none = V.finalityWords({ paused: false, last_lock: 0, message: 'waiting for the miner' }); + assert.equal(none.age, 'n/a'); assert.equal(none.note, 'waiting for the miner'); + // N5: a node built without the mining engine is a fault + assert.equal(V.nodeWords({ state: 'stub', blocks: 0, headers: 0, peers: 0 }, { severity: 'none' }, '').word, 'stub engine'); + assert.deepEqual(V.pill({ setup_done: true, mining: { state: 'waiting', hash_total: 0, cards: [] }, node: { state: 'stub' } }), { text: 'Node fault', tone: 'bad' }); +}); + test('whose node (publish 2): the app’s own, another node on this machine checked or unchecked, or none when the ports are taken by other rules', () => { assert.deepEqual(V.nodeSource({ source: 'app' }), { text: 'this app’s own node', sub: '' }); assert.deepEqual(V.nodeSource({ source: 'external', rules_check: 'match' }), { text: 'another node on this machine', sub: 'same network and rules, checked' }); diff --git a/docs/plans/miner-ui-4.md b/docs/plans/miner-ui-4.md index e443a81b..12d3754b 100644 --- a/docs/plans/miner-ui-4.md +++ b/docs/plans/miner-ui-4.md @@ -92,3 +92,9 @@ file is superseded and its `source` word is "site" too, so nothing in the UI cha | The fleet watts count mining and tuning cards only (PC 2's idle integrated card drew 48 W and was summed); the Ember off-state line no longer promises a start. | `ui/app.js` | Tests: UI 39; the app crate on the box 152 passed (the N4 pair, the shot path, the four live tests among them). + +## 9. 0.3.17 fold (7 October 2026, 23:20 UK) + +- Rebased onto a9eb58f1 (origin/release-0.3.15). ui/ comes wholly from this branch; Ember's finality-paused hunk (ember-tune 1357d280) is folded in as `View.finalityWords(f)`: while finality is paused the node sub-line and the lock row say "paused" and nothing calls a lock final. Test in view.test.mjs (41 UI tests). +- src/extnode.rs reads the node lane's `igneum_getNodeInfo` shape: `params` (every resolved consensus field, compared value by value against the manifest's keys), `network` (must be equal), `powEngine` ("stub" = refused on the port check, and a fault on the app's own node: node state `stub`, pill "Node fault", one error event). 168 box tests. +- src/live.rs is Ember's merged module at the merge (nothing of this branch's lives there any more).