Merge signing-22 c19f1a7f into release-0.3.22 (the project lead's signing-bonus plan, the app half: vote on by default pinned by test, the Overview and Cards rows read signing or silent with the reason)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
igneum-labs 2026-10-07 17:55:30 +00:00
commit cbb5e6bc45
4 changed files with 97 additions and 6 deletions

View file

@ -180,6 +180,13 @@ 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
}
@ -459,6 +466,18 @@ 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 ""

View file

@ -2274,8 +2274,8 @@ impl Engine {
a.push("--identities".into());
a.push(card.identities.to_string());
}
if !s.vote {
a.push("--no-vote".into());
if let Some(f) = crate::config::vote_flag(s.vote) {
a.push(f.into());
}
if !s.dev_fee {
// the miner software's dev fee (default 1% of templates); the switch in Settings

View file

@ -601,12 +601,38 @@ 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"
function nodeLine(n, clock, eta) {
// ---------- 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) {
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');
return { text: parts.join(' · '), sub: w.line, tone: w.tone };
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;
}
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'; }
@ -1276,7 +1302,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 { 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 { 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 };
})();
/* ---------- 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
@ -2272,6 +2298,8 @@ if (typeof document !== 'undefined') (function () {
var subs = [];
if (r.sub) subs.push('<div class="msg' + (r.tone === 'bad' ? '' : ' dim') + '">' + esc(r.sub) + '</div>');
if (cells.noReading && r.on) subs.push('<div class="msg dim">' + esc(cells.noReading) + '</div>');
var sg = View.signingWords(s, s.now, cd);
if (sg.word) subs.push('<div class="msg sign' + (sg.state === 'silent' ? '' : ' dim') + '" title="' + esc(sg.reason) + '">' + esc(sg.word) + (sg.state === 'signing' ? '' : ': ' + esc(sg.reason)) + '</div>');
var dw = View.driverWords(cd, s);
var drv = dw.kind === 'none' || dw.kind === 'fine' ? '' : '<div class="gl-driver ' + dw.kind + '">' + (dw.kind === 'running' ? '<i class="bar"><b style="width:' + Math.round((dw.progress || 0) * 100) + '%"></b></i>' : '') + '<span class="t">' + esc(dw.text) + '</span>' +
(dw.button === 'install' ? '<button class="btn small primary" data-driver-install="' + esc(cd.driver_offer.vendor) + '">Install</button>' : dw.button === 'restart' ? '<button class="btn small primary" data-driver-restart="1">Restart now</button>' : '') +
@ -2408,7 +2436,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), fw = View.finalityWords(f);
var nl = View.nodeLine(n, s.clock, eta, View.signingWords(s, s.now)), 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');

View file

@ -1051,3 +1051,47 @@ 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);
});