diff --git a/app/igneum-app/src/config.rs b/app/igneum-app/src/config.rs
index 05a2d1f1..761979d8 100644
--- a/app/igneum-app/src/config.rs
+++ b/app/igneum-app/src/config.rs
@@ -180,13 +180,6 @@ fn balanced() -> String {
fn nineteen() -> f64 {
19.0
}
-/// signing-22 (7 October 2026, the project lead's signing-bonus ruling): the one place the miner's vote flag comes from. Voting is
-/// on by default (`Settings::default`, and an old settings file without the field reads true through `yes`), so the
-/// flag exists only when the user turned it off; a silent key then shows on every card row as "silent: voting is off".
-pub fn vote_flag(vote: bool) -> Option<&'static str> {
- if vote { None } else { Some("--no-vote") }
-}
-
fn yes() -> bool {
true
}
@@ -466,18 +459,6 @@ impl Runtime {
#[cfg(test)]
mod tests {
- #[test]
- fn vote_defaults_on() {
- // signing-22: the finality section is signed by default; an old file without the field is not a silent key
- assert!(super::Settings::default().vote);
- let old: super::Settings = serde_json::from_str("{}").unwrap();
- assert!(old.vote, "a settings file without `vote` reads true");
- let off: super::Settings = serde_json::from_str(r#"{"vote":false}"#).unwrap();
- assert!(!off.vote);
- assert_eq!(super::vote_flag(true), None);
- assert_eq!(super::vote_flag(false), Some("--no-vote"));
- }
-
#[test]
fn currency_round_trip() {
// currency-21: the override is a field of the settings file, so it survives a restart; an old file without it reads as ""
diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs
index 714b27d4..2e98ca44 100644
--- a/app/igneum-app/src/engine.rs
+++ b/app/igneum-app/src/engine.rs
@@ -2274,8 +2274,8 @@ impl Engine {
a.push("--identities".into());
a.push(card.identities.to_string());
}
- if let Some(f) = crate::config::vote_flag(s.vote) {
- a.push(f.into());
+ if !s.vote {
+ a.push("--no-vote".into());
}
if !s.dev_fee {
// the miner software's dev fee (default 1% of templates); the switch in Settings
diff --git a/app/igneum-app/ui/app.js b/app/igneum-app/ui/app.js
index 7ede508a..8e995b7d 100644
--- a/app/igneum-app/ui/app.js
+++ b/app/igneum-app/ui/app.js
@@ -601,38 +601,12 @@ var View = (function () {
// "12 s" / "4 min" / "53 min" / "2 h" for a tip age
function tipAge(s) { s = Math.max(0, Math.round(s)); return s < 90 ? s + ' s' : s < 5400 ? Math.round(s / 60) + ' min' : Math.round(s / 3600) + ' h'; }
// the one node line on Mine: "Node synced · 6 peers · 109,859 blocks · last block 4 s ago"
- // ---------- the signing state (signing-22, the project lead's signing-bonus ruling, 7 October 2026) ----------
- // The miner signs the finality section by default; this says whether the key is signing now, or why it is silent:
- // voting off, a missing vote key, a clock block, no template (the node not synced), or a key that stopped while the
- // chain kept locking. With a card it reads that card's own keys and says nothing for a card that is off.
- var SIGN_GAP_S = 90;
- function agoWords(sec) { sec = Math.max(0, Math.round(sec)); return sec < 90 ? sec + ' s ago' : tipAge(sec) + ' ago'; }
- function signingWords(s, now, cd) {
- s = s || {}; var st = s.settings || {}, n = s.node || {}, c = s.clock || {}, ld = s.ladder || {}, f = s.finality || {}, m = s.mining || {};
- if (cd && !(cd.enabled && !(cd.removed_at > 0) && !cd.problem)) return { state: '', word: '', reason: '', tone: '' };
- var keys = cd ? (cd.ids || []).length > 0 : (m.cards || []).some(function (x) { return (x.ids || []).length > 0; });
- var silent = function (reason) { return { state: 'silent', word: 'silent', reason: reason, tone: 'bad' }; };
- if (st.vote === false) return silent('voting is off in Settings');
- if (c.severity === 'block') return silent('the clock is ' + skewWord(c.skew_s) + ' ' + (c.skew_s < 0 ? 'behind' : 'ahead of') + ' the network; signatures would be refused');
- if (!n.synced) return silent('no template yet: the node is ' + (n.state || 'starting'));
- if (!keys) return silent('no vote key: the miner started without one');
- var voteAge = ld.last_vote_at > 0 ? now - ld.last_vote_at : -1, lockAge = f.last_lock_at > 0 ? now - f.last_lock_at : -1;
- if (voteAge >= 0 && voteAge <= SIGN_GAP_S) return { state: 'signing', word: 'signing', reason: 'checkpoint ' + withCommas(ld.last_vote_index || 0) + ' signed ' + agoWords(voteAge), tone: 'on' };
- if (lockAge < 0 || lockAge > SIGN_GAP_S) return { state: 'paused', word: 'paused', reason: lockAge < 0 ? 'the network has not locked a checkpoint yet' : 'the network has not locked a checkpoint for ' + tipAge(lockAge), tone: '' };
- if (voteAge < 0 && !(f.votes > 0) && (s.uptime_s || 0) < 120) return { state: 'waiting', word: 'first checkpoint pending', reason: 'the first signature comes within a checkpoint of the sync', tone: '' };
- return silent(voteAge < 0 ? 'no checkpoint signed since start while the chain locked ' + withCommas(f.last_lock || 0) : 'no checkpoint signed for ' + tipAge(voteAge) + ' while the chain locked ' + withCommas(f.last_lock || 0));
- }
- function nodeLine(n, clock, eta, sign) {
+ function nodeLine(n, clock, eta) {
var w = nodeWords(n, clock, eta), parts = ['Node ' + w.word];
if (n.peers > 0) parts.push(n.peers + ' peer' + (n.peers === 1 ? '' : 's'));
if (n.blocks > 0) parts.push(withCommas(n.blocks) + ' blocks');
if (n.tip_age_s >= 0 && (n.state === 'synced' || n.state === 'behind')) parts.push('last block ' + tipAge(n.tip_age_s) + ' ago');
- var out = { text: parts.join(' · '), sub: w.line, tone: w.tone };
- if (sign && sign.word) {
- out.text += ' · ' + sign.word;
- if (sign.state === 'silent' && w.tone !== 'bad') { out.tone = 'bad'; out.sub = 'Silent: ' + sign.reason + '.'; }
- }
- return out;
+ return { text: parts.join(' · '), sub: w.line, tone: w.tone };
}
function skewWord(sk) { sk = Math.round(Math.abs(sk || 0)); return sk < 120 ? sk + ' s' : sk < 7200 ? Math.floor(sk / 60) + ' min ' + (sk % 60) + ' s' : Math.floor(sk / 3600) + ' h ' + Math.floor(sk % 3600 / 60) + ' min'; }
function peersLine(n) { return n.peers === 0 ? 'none yet: looking for the seed node' : n.peers === 1 ? 'one other node this one talks to' : 'other nodes this one talks to'; }
@@ -1302,7 +1276,7 @@ var View = (function () {
}
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 { signingWords: signingWords, SIGN_GAP_S: SIGN_GAP_S, ISO_CURRENCY: ISO_CURRENCY, EU_MEMBERS: EU_MEMBERS, CURRENCY_CODES: CURRENCY_CODES, minorOf: minorOf, priceFactor: priceFactor, currencyInfo: currencyInfo, regionFromLocale: regionFromLocale, currencyChoice: currencyChoice, currencyAsk: currencyAsk, applyMoney: applyMoney, fmtMoney: fmtMoney, setLocale: setLocale, setCurrency: setCurrency, currencyNow: currencyNow, unitWords: unitWords, VENDORS: VENDORS, MARKS: MARKS, WELL_ALPHA_LIGHT: WELL_ALPHA_LIGHT, WELL_ALPHA_DARK: WELL_ALPHA_DARK, vendorOf: vendorOf, seriesLine: seriesLine, vendorMark: vendorMark, markHtml: markHtml, nameHtml: nameHtml, rowHead: rowHead, vendorContrast: vendorContrast, contrast: contrast, hexRgb: hexRgb, 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, driverWords: driverWords, vendorWord: vendorWord, 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, proveInsteadWords: proveInsteadWords, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, firstWait: firstWait, staleCard: staleCard, cardSeenBody: cardSeenBody, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords, REGIONS: REGIONS, CURRENCIES: CURRENCIES, RESTRICTED_SENTENCE: RESTRICTED_SENTENCE, regionOf: regionOf, currencyOf: currencyOf, setRegion: setRegion, restrictedLine: restrictedLine, priceNote: priceNote, RENT: RENT, rentLine: rentLine, heatLine: heatLine, heatRowWords: heatRowWords };
+ return { ISO_CURRENCY: ISO_CURRENCY, EU_MEMBERS: EU_MEMBERS, CURRENCY_CODES: CURRENCY_CODES, minorOf: minorOf, priceFactor: priceFactor, currencyInfo: currencyInfo, regionFromLocale: regionFromLocale, currencyChoice: currencyChoice, currencyAsk: currencyAsk, applyMoney: applyMoney, fmtMoney: fmtMoney, setLocale: setLocale, setCurrency: setCurrency, currencyNow: currencyNow, unitWords: unitWords, VENDORS: VENDORS, MARKS: MARKS, WELL_ALPHA_LIGHT: WELL_ALPHA_LIGHT, WELL_ALPHA_DARK: WELL_ALPHA_DARK, vendorOf: vendorOf, seriesLine: seriesLine, vendorMark: vendorMark, markHtml: markHtml, nameHtml: nameHtml, rowHead: rowHead, vendorContrast: vendorContrast, contrast: contrast, hexRgb: hexRgb, 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, driverWords: driverWords, vendorWord: vendorWord, 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, proveInsteadWords: proveInsteadWords, proveLine: proveLine, devFeeText: devFeeText, devFeeLine: devFeeLine, jobsNote: jobsNote, eventLine: eventLine, plainEvent: plainEvent, pill: pill, beforeOf: beforeOf, ember: ember, fleetSaving: fleetSaving, addressAsk: addressAsk, trustAsk: trustAsk, interfaceWords: interfaceWords, shouldReload: shouldReload, FIELDS: FIELDS, fmtIgn: fmtIgn, pct: pct, gapWords: gapWords, spanWords: spanWords, chainNums: chainNums, poisson: poisson, ignPerDay: ignPerDay, windowWords: windowWords, daysIn: daysIn, ladderRungs: ladderRungs, currentRung: currentRung, firstWait: firstWait, staleCard: staleCard, cardSeenBody: cardSeenBody, blockCard: blockCard, earningsLines: earningsLines, cardIgnDay: cardIgnDay, timeline: timeline, profileWords: profileWords, REGIONS: REGIONS, CURRENCIES: CURRENCIES, RESTRICTED_SENTENCE: RESTRICTED_SENTENCE, regionOf: regionOf, currencyOf: currencyOf, setRegion: setRegion, restrictedLine: restrictedLine, priceNote: priceNote, RENT: RENT, rentLine: rentLine, heatLine: heatLine, heatRowWords: heatRowWords };
})();
/* ---------- 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
@@ -2300,8 +2274,6 @@ if (typeof document !== 'undefined') (function () {
var subs = [];
if (r.sub) subs.push('
' + esc(r.sub) + '
');
if (cells.noReading && r.on) subs.push('' + esc(cells.noReading) + '
');
- var sg = View.signingWords(s, s.now, cd);
- if (sg.word) subs.push('' + esc(sg.word) + (sg.state === 'signing' ? '' : ': ' + esc(sg.reason)) + '
');
var dw = View.driverWords(cd, s);
var drv = dw.kind === 'none' || dw.kind === 'fine' ? '' : '' + (dw.kind === 'running' ? '' : '') + '' + esc(dw.text) + '' +
(dw.button === 'install' ? '' : dw.button === 'restart' ? '' : '') +
@@ -2438,7 +2410,7 @@ 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, View.signingWords(s, s.now)), fw = View.finalityWords(f);
+ 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');
diff --git a/app/igneum-app/ui/view.test.mjs b/app/igneum-app/ui/view.test.mjs
index af5f0a9f..d3b9aab4 100644
--- a/app/igneum-app/ui/view.test.mjs
+++ b/app/igneum-app/ui/view.test.mjs
@@ -1051,47 +1051,3 @@ test('a locale with no currency row falls back to USD with the override prompt v
assert.equal(V.currencyAsk(V.currencyChoice({}, 'en-GB')), '');
assert.ok(pageHtml.includes('id="s-currency-ask"') && pageHtml.includes('id="region-currency-ask"'), 'the prompt element on Settings and on the first-run step');
});
-
-// ---------- signing state (signing-22, the project lead's signing-bonus ruling, 7 October 2026) ----------
-// The miner signs the finality section by default (settings.vote true, an old settings file without the field reads
-// true, the miner starts without --no-vote); the Overview's node line and every card row say "signing" while the key
-// signs and "silent" with the reason when it does not (voting off, a missing key, a clock block, no template), so a
-// silent key from a bug is visible before any bonus height is set.
-test('signing on by default: Settings.vote true in the Rust default and for an old file without the field; the miner flag only when off', () => {
- const cfg = rustSrc('config.rs');
- assert.match(cfg, /#\[serde\(default = "yes"\)\]\s*\n\s*pub vote: bool/, 'an old settings file without the field reads true');
- assert.match(cfg, /fn vote_defaults_on/, 'the Rust test exists');
- assert.match(cfg, /pub fn vote_flag\(vote: bool\)/, 'the one place the --no-vote flag comes from');
- assert.match(rustSrc('engine.rs'), /crate::config::vote_flag\(s\.vote\)/, 'miner_args uses it');
-});
-
-test('"signing" when the key signs; "silent" with the reason: voting off, a missing key, a clock block, no template, a stale key', () => {
- const base = stateOf({ uptime_s: 1800, settings: { ...stateOf().settings, vote: true }, node: { synced: true, state: 'synced' }, clock: { severity: 'none', skew_s: 0 }, finality: { last_lock: 8495, last_lock_at: T5 - 12, votes: 240 }, ladder: { ...stateOf().ladder, votes_signed: 240, last_vote_index: 8495, last_vote_at: T5 - 12 } });
- const on = V.signingWords(base, T5);
- assert.equal(on.state, 'signing'); assert.equal(on.word, 'signing'); assert.equal(on.tone, 'on');
- assert.equal(on.reason, 'checkpoint 8,495 signed 12 s ago');
- const off = V.signingWords({ ...base, settings: { ...base.settings, vote: false } }, T5);
- assert.equal(off.state, 'silent'); assert.equal(off.word, 'silent'); assert.equal(off.tone, 'bad'); assert.equal(off.reason, 'voting is off in Settings');
- const nokey = V.signingWords({ ...base, mining: { ...base.mining, cards: [card({ ids: [] })] } }, T5);
- assert.equal(nokey.state, 'silent'); assert.equal(nokey.reason, 'no vote key: the miner started without one');
- const clock = V.signingWords({ ...base, clock: { severity: 'block', skew_s: -412 } }, T5);
- assert.equal(clock.state, 'silent'); assert.equal(clock.reason, 'the clock is 6 min 52 s behind the network; signatures would be refused');
- const tpl = V.signingWords({ ...base, node: { synced: false, state: 'syncing' } }, T5);
- assert.equal(tpl.state, 'silent'); assert.equal(tpl.reason, 'no template yet: the node is syncing');
- const stale = V.signingWords({ ...base, ladder: { ...base.ladder, last_vote_at: T5 - 400 } }, T5);
- assert.equal(stale.state, 'silent'); assert.equal(stale.reason, 'no checkpoint signed for 7 min while the chain locked 8,495');
- const first = V.signingWords({ ...base, uptime_s: 40, finality: { ...base.finality, votes: 0 }, ladder: { ...base.ladder, votes_signed: 0, last_vote_at: 0 } }, T5);
- assert.equal(first.state, 'waiting'); assert.equal(first.word, 'first checkpoint pending'); assert.equal(first.tone, '');
- const paused = V.signingWords({ ...base, finality: { ...base.finality, last_lock_at: T5 - 900 }, ladder: { ...base.ladder, last_vote_at: T5 - 400 } }, T5);
- assert.equal(paused.state, 'paused'); assert.equal(paused.reason, 'the network has not locked a checkpoint for 15 min');
- // one card's row: its own keys decide the missing-key case
- const row = V.signingWords(base, T5, card({ ids: [] })); assert.equal(row.state, 'silent');
- assert.equal(V.signingWords(base, T5, card({ ids: ['6ad0e117'] })).state, 'signing');
- assert.equal(V.signingWords(base, T5, card({ enabled: false, state: 'off', ids: ['6ad0e117'] })).state, '', 'an off card signs nothing and says nothing');
- // the node line carries the word
- const nl = V.nodeLine({ state: 'synced', synced: true, peers: 4, blocks: 39571, tip_age_s: 3 }, { severity: 'none' }, '', on);
- assert.equal(nl.text, 'Node synced · 4 peers · 39,571 blocks · last block 3 s ago · signing');
- const nls = V.nodeLine({ state: 'synced', synced: true, peers: 4, blocks: 39571, tip_age_s: 3 }, { severity: 'none' }, '', off);
- assert.equal(nls.text, 'Node synced · 4 peers · 39,571 blocks · last block 3 s ago · silent'); assert.equal(nls.tone, 'bad'); assert.equal(nls.sub, 'Silent: voting is off in Settings.');
- for (const w of [on, off, nokey, clock, tpl, stale, first, paused]) assert.equal(/—|–/.test(w.reason + w.word), false);
-});
diff --git a/pool/tools/dn3-split-read.mjs b/pool/tools/dn3-split-read.mjs
deleted file mode 100644
index 3c1747c7..00000000
--- a/pool/tools/dn3-split-read.mjs
+++ /dev/null
@@ -1,113 +0,0 @@
-#!/usr/bin/env node
-// The Devnet 3 read for the pool split's activation (the node lane's 6.11 condition, 7 October 2026): over a window of
-// chain blocks on the hub node, the first and last chain block (number, hash, UTC time), the blocks whose coinbase
-// carries the share chain's commitment (IGNS) and the window's split (IGNP), and the executed state root of the window's
-// last block on every node named, so the node lane can set `pool_split_activation_daa` at the hour's last block + 7,200.
-// Reads chain-side facts only (getVirtualChainFromBlock, getBlock with transactions, igneum_getExecStatus and
-// eth_getBlockByNumber on the exec RPC); never a process name, never a reported rate.
-//
-// node pool/tools/dn3-split-read.mjs --hub ws://dn3-g1:28610 --hub-exec http://dn3-g1:26790 \
-// --node ws://:28610=http://:26790 --node ws://:28610=http://:26790 \
-// --from-daa [--to-daa ] [--out ]
-//
-// The hub's chain between the two DAA scores is walked from the pruning point or a block below `--from-daa` through
-// getVirtualChainFromBlock; every chain block and its mergeset blues are read with transactions; the coinbase payload
-// (hex) is scanned for the tags. Exec roots: igneum_getExecStatus on every node (executed tip, hash), then
-// eth_getBlockByNumber at the lowest executed tip across the nodes, stateRoot compared.
-
-import { connectRpc } from '../../tools/finality-attacks/lib/rpc.mjs';
-import { writeFileSync } from 'node:fs';
-
-const args = process.argv.slice(2);
-const opt = (n, d) => { const i = args.indexOf(n); return i >= 0 && args[i + 1] !== undefined ? args[i + 1] : d; };
-const all = (n) => args.map((a, i) => (a === n ? args[i + 1] : null)).filter(Boolean);
-const HUB = opt('--hub', 'ws://127.0.0.1:28610');
-const HUB_EXEC = opt('--hub-exec', 'http://127.0.0.1:26790');
-const NODES = all('--node').map(s => { const [ws, exec] = s.split('='); return { ws, exec }; });
-const FROM = +opt('--from-daa', 0);
-const TO = opt('--to-daa', null) == null ? null : +opt('--to-daa');
-const OUT = opt('--out', null);
-const log = (...a) => console.log(new Date().toISOString().slice(11, 23), ...a);
-
-let rpcId = 0;
-async function execCall(url, method, params = []) {
- const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: ++rpcId, method, params }) });
- const j = await r.json(); if (j.error) throw new Error(`${method}: ${j.error.message || JSON.stringify(j.error)}`); return j.result;
-}
-const hexToBytes = (h) => Buffer.from(h.replace(/^0x/, ''), 'hex');
-const hasTag = (payloadHex, tag) => hexToBytes(payloadHex).includes(Buffer.from(tag, 'ascii'));
-// the split's entries, from IGNP || 4 hex count || count x (40 hex address || 8 hex weight) (consensus/core/src/evm.rs)
-function splitOf(payloadHex) {
- const b = hexToBytes(payloadHex); const i = b.indexOf(Buffer.from('IGNP', 'ascii')); if (i < 0) return null;
- const body = b.subarray(i + 4).toString('latin1'); const n = parseInt(body.slice(0, 4), 16); if (!(n > 0)) return null;
- const out = []; for (let k = 0; k < n; k++) { const e = body.slice(4 + k * 48, 4 + k * 48 + 48); if (e.length < 48) return null; out.push({ address: '0x' + e.slice(0, 40), weight: parseInt(e.slice(40, 48), 16) }); }
- return out;
-}
-
-const hub = await connectRpc(HUB, { attempts: 10, waitMs: 1000 });
-if (!hub) { console.error(`no RPC at ${HUB}`); process.exit(2); }
-const info = await hub.call('getBlockDagInfo', {});
-log(`hub ${HUB}: network ${info.networkName || info.network}, virtual DAA ${info.virtualDaaScore}, pruning point ${String(info.pruningPointHash).slice(0, 16)}`);
-const toDaa = TO ?? Number(info.virtualDaaScore);
-// walk the selected chain from the pruning point (the hub's own view), keeping chain blocks within the DAA window
-let cursor = info.pruningPointHash;
-const chainBlocks = []; let steps = 0;
-while (steps++ < 2000) {
- const r = await hub.call('getVirtualChainFromBlock', { startHash: cursor, includeAcceptedTransactionIds: false });
- const added = r.addedChainBlockHashes || [];
- if (!added.length) break;
- for (const h of added) {
- const b = (await hub.call('getBlock', { hash: h, includeTransactions: true })).block;
- const daa = Number(b.header.daaScore);
- if (daa >= FROM && daa <= toDaa) chainBlocks.push(b);
- if (daa > toDaa) { steps = 1e9; break; }
- }
- cursor = added[added.length - 1];
- if (added.length < 2) break;
-}
-if (!chainBlocks.length) { console.error(`no chain blocks between DAA ${FROM} and ${toDaa} on the hub`); process.exit(3); }
-// every block the window pays: the chain blocks and their mergeset blues
-const seen = new Set(); const blocks = [];
-for (const cb of chainBlocks) {
- const blues = [cb.verboseData?.hash || cb.header.hash, ...(cb.verboseData?.mergeSetBluesHashes || [])];
- for (const h of blues) {
- if (seen.has(h)) continue; seen.add(h);
- const b = h === (cb.verboseData?.hash || cb.header.hash) ? cb : (await hub.call('getBlock', { hash: h, includeTransactions: true })).block;
- const cbTx = (b.transactions || [])[0];
- const payload = cbTx?.payload || '';
- const split = splitOf(payload);
- blocks.push({ hash: h, daa: Number(b.header.daaScore), ts: Number(b.header.timestamp), chain: b === cb, igns: hasTag(payload, 'IGNS'), ignp: !!split, igna: hasTag(payload, 'IGNA'), split_entries: split ? split.length : 0, vote_key_hash: b.header.voteKeyHash || b.header.vote_key_hash });
- }
-}
-const first = chainBlocks[0], last = chainBlocks[chainBlocks.length - 1];
-const iso = (ms) => new Date(Number(ms)).toISOString();
-const withBoth = blocks.filter(b => b.igns && b.ignp);
-const onlyOne = blocks.filter(b => b.igns !== b.ignp);
-log(`window: ${chainBlocks.length} chain blocks, ${blocks.length} blue blocks; first chain block DAA ${first.header.daaScore} ${String(first.verboseData?.hash || first.header.hash).slice(0, 16)} at ${iso(first.header.timestamp)}; last DAA ${last.header.daaScore} ${String(last.verboseData?.hash || last.header.hash).slice(0, 16)} at ${iso(last.header.timestamp)}`);
-log(`blocks carrying IGNS and IGNP: ${withBoth.length} (${withBoth.filter(b => b.chain).length} chain blocks); one tag without the other: ${onlyOne.length}; distinct vote keys among the split blocks: ${new Set(withBoth.map(b => b.vote_key_hash)).size}; split entries min/max: ${withBoth.length ? Math.min(...withBoth.map(b => b.split_entries)) : 0}/${withBoth.length ? Math.max(...withBoth.map(b => b.split_entries)) : 0}`);
-// exec roots across the nodes at one executed height
-const nodes = [{ name: 'hub', ws: HUB, exec: HUB_EXEC }, ...NODES.map((n, i) => ({ name: `node${i + 1}`, ...n }))];
-const statuses = [];
-for (const n of nodes) { try { const s = await execCall(n.exec, 'igneum_getExecStatus', []); statuses.push({ name: n.name, exec: n.exec, tip: Number(s.executedTip), tipHash: s.executedTipHash, tipDaa: Number(s.executedTipDaa) }); } catch (e) { statuses.push({ name: n.name, exec: n.exec, error: e.message }); } }
-const lowest = Math.min(...statuses.filter(s => s.tip != null).map(s => s.tip));
-const roots = [];
-for (const s of statuses) {
- if (s.tip == null) { roots.push({ name: s.name, error: s.error }); continue; }
- try { const b = await execCall(s.exec, 'eth_getBlockByNumber', ['0x' + lowest.toString(16), false]); roots.push({ name: s.name, number: lowest, hash: b?.hash, stateRoot: b?.stateRoot }); } catch (e) { roots.push({ name: s.name, number: lowest, error: e.message }); }
-}
-const distinctRoots = new Set(roots.filter(r => r.stateRoot).map(r => r.stateRoot));
-log(`exec: ${statuses.map(s => `${s.name} tip ${s.tip ?? 'ERR'}`).join(', ')}; state root at executed block ${lowest} on ${roots.filter(r => r.stateRoot).length} nodes: ${distinctRoots.size === 1 ? 'AGREE ' + [...distinctRoots][0].slice(0, 18) : 'DISAGREE ' + roots.map(r => `${r.name}=${String(r.stateRoot).slice(0, 12)}`).join(' ')}`);
-const result = {
- read_at: new Date().toISOString(), hub: HUB, network: info.networkName || info.network, from_daa: FROM, to_daa: toDaa,
- first_chain_block: { number_daa: Number(first.header.daaScore), blue_score: Number(first.header.blueScore), hash: first.verboseData?.hash || first.header.hash, time: iso(first.header.timestamp) },
- last_chain_block: { number_daa: Number(last.header.daaScore), blue_score: Number(last.header.blueScore), hash: last.verboseData?.hash || last.header.hash, time: iso(last.header.timestamp) },
- activation_daa_if_set_now: Number(last.header.daaScore) + 7200,
- chain_blocks: chainBlocks.length, blue_blocks: blocks.length, blocks_with_igns_and_ignp: withBoth.length, chain_blocks_with_split: withBoth.filter(b => b.chain).length, blocks_with_one_tag_only: onlyOne.length,
- distinct_vote_keys_in_split_blocks: new Set(withBoth.map(b => b.vote_key_hash)).size,
- exec: { statuses, roots_at: lowest, roots, agree: distinctRoots.size === 1 },
- split_blocks: withBoth.map(b => ({ hash: b.hash, daa: b.daa, time: iso(b.ts), chain: b.chain, entries: b.split_entries })),
- source: 'hub getBlockDagInfo, getVirtualChainFromBlock, getBlock(includeTransactions) coinbase payload tags; every node igneum_getExecStatus and eth_getBlockByNumber at the lowest executed tip',
-};
-if (OUT) { writeFileSync(OUT, JSON.stringify(result, null, 2)); log(`written ${OUT}`); }
-console.log(JSON.stringify({ ...result, split_blocks: result.split_blocks.length }, null, 1));
-process.exit(0);
diff --git a/tools/scene/legend.test.mjs b/tools/scene/legend.test.mjs
index 2ccaaedf..f972a50e 100644
--- a/tools/scene/legend.test.mjs
+++ b/tools/scene/legend.test.mjs
@@ -39,19 +39,28 @@ test('every colour the scene draws on a block or a band has a key entry', () =>
for (const t of drawn) { assert.ok(read.includes(t), 'the scene reads ' + t); assert.ok(tokens.has(t), 'the key has an entry in ' + t); }
});
-// the site tree on a release-* branch is frozen and deploys from master alone (tools/scene/sync.mjs's scope rule), so the
-// site half of the check binds on master (or with SCENE_SYNC_SCOPE=site) and is a diagnostic elsewhere
+// the /live key as ruled (7 October 2026): Included, Excluded, Selected chain, Proven, Locked checkpoint; no Pending and no
+// Your blocks (the site has no "you"). The exceptions are the ruling's; every entry the two surfaces share must carry the
+// same wording and the same token as the export, so the swatches cannot drift apart again.
+const SITE_RULED = SITE_ORDER.filter((l) => l !== 'Pending');
+// the site deploys from master alone and a release-* branch carries site/ frozen as it was cut (tools/scene/sync.mjs's scope
+// rule), so the site half binds on master (or SCENE_SYNC_SCOPE=site, or a site/live.html passed as SITE_LIVE_HTML) and is a
+// diagnostic on a release branch
const branch = (() => { try { return execSync('git rev-parse --abbrev-ref HEAD', { cwd: root, stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim(); } catch (e) { return ''; } })();
-const siteBinds = process.env.SCENE_SYNC_SCOPE === 'site' || branch === 'master';
+const siteBinds = process.env.SCENE_SYNC_SCOPE === 'site' || !!process.env.SITE_LIVE_HTML || branch === 'master';
+const siteEntries = (html) => [...html.matchAll(/<\/i>([^<]+)<\/span>/g)].map((m) => { const tok = /var\((--[a-z0-9-]+)\)/.exec(m[2] || ''); return { label: m[3].trim(), token: tok ? tok[1] : (/\btick\b/.test(m[1]) ? '--bone' : ''), shape: /\btick\b/.test(m[1]) ? 'tick' : /border-radius:50%/.test(m[2] || '') ? 'circle' : 'square' }; });
-test('the app key and the site key are the export, not a hand-written list: no literal entries left, and any literal list that remains equals the export in labels and order', (t) => {
- const app = readFileSync(join(root, 'app/igneum-app/ui/index.html'), 'utf8'), site = readFileSync(join(root, 'site/live.html'), 'utf8');
- const appLit = literal(app, 'lg'), siteLit = literal(site, 'lgi');
+test('the app key is the export, not a hand-written list; the site key is the export minus the ruled exceptions (Pending, Your blocks), same wording and tokens on every shared entry', (t) => {
+ const app = readFileSync(join(root, 'app/igneum-app/ui/index.html'), 'utf8'), site = readFileSync(process.env.SITE_LIVE_HTML || join(root, 'site/live.html'), 'utf8');
+ const appLit = literal(app, 'lg');
assert.ok(/data-legend="mine"/.test(app), 'the app legend renders from IgneumDag.legend({ mine: true })');
assert.deepEqual(appLit, [], 'hand-written entries in the app key: ' + appLit.join(', '));
- const siteOk = siteLit.length ? JSON.stringify(siteLit) === JSON.stringify(SITE_ORDER) : /data-legend/.test(site);
- if (siteBinds) assert.ok(siteOk, 'the site key differs from the export: ' + (siteLit.length ? siteLit.join(', ') : 'no data-legend hook'));
- else if (!siteOk) t.diagnostic('site/live.html still carries a hand-written key (' + siteLit.join(', ') + '); it binds on master once the site lane renders it from IgneumDag.renderLegend');
+ const got = siteEntries(site), exp = Dag.legend({ mine: false }).filter((e) => e.label !== 'Pending'), problems = [];
+ if (JSON.stringify(got.map((e) => e.label)) !== JSON.stringify(SITE_RULED)) problems.push('the /live key reads ' + got.map((e) => e.label).join(', ') + ', ruled: ' + SITE_RULED.join(', '));
+ for (const e of exp) { const g = got.find((x) => x.label === e.label); if (!g) continue; if (g.token !== e.token) problems.push(e.label + ' token on /live is ' + g.token + ', the export says ' + e.token); if (g.shape !== e.shape) problems.push(e.label + ' shape on /live is ' + g.shape + ', the export says ' + e.shape); }
+ if (got.some((e) => /your/i.test(e.label))) problems.push('the site shows Your block; the ruling has none');
+ if (siteBinds) assert.deepEqual(problems, [], 'the /live key against the export:\n' + problems.join('\n'));
+ else if (problems.length) t.diagnostic('site/live.html on this release branch is the frozen cut (' + problems.join('; ') + '); the check binds on master, where the site deploys from');
// the app's source line stays
assert.match(readFileSync(join(root, 'app/igneum-app/ui/app.js'), 'utf8'), /From Igneum’s observer\. Your blocks are ringed\./);
// the copies are the source (the sync gate says the same; this fails first when a legend edit misses the sync)