Ember for Miner UI 4 (0.3.16): state.mining.watts_total and pounds_per_day (the fleet's fresh draw, the price from settings), state.address.balance_wei with balance_age_s and balance_note (eth_getBalance through the node's RPC every 30 s), state.address.price_gbp_per_ign null with its one documented source (a signed manifest field); GET /api/live from a local source (src/live.rs: igneum_getRecentBlocks when the node carries it, else the public site's reply cached 60 s, source and age_s on every reply); tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 0276163301)
This commit is contained in:
igneum-labs 2026-10-06 19:24:38 +00:00
parent 568395b78b
commit 4e4b1fab15
7 changed files with 377 additions and 36 deletions

View file

@ -906,6 +906,21 @@ pub fn tuned_line(mhs: f64, watts: f64, eff: f64) -> String {
format!("Tuned: {mhs:.1} MH/s at {watts:.0} W ({eff:.3} MH/W)") format!("Tuned: {mhs:.1} MH/s at {watts:.0} W ({eff:.3} MH/W)")
} }
/// Miner UI 4 (6 October 2026): the fleet's draw now: the sum over cards that mine and have a draw reading under
/// `max_age_s` old (a stale reading is not a draw).
pub fn fleet_watts<'a>(cards: impl Iterator<Item = (&'a str, f64, f64)>, now: f64, max_age_s: f64) -> f64 {
cards.filter(|(state, w, at)| *state == "mining" && *w > 0.0 && now - *at < max_age_s).map(|(_, w, _)| w).sum()
}
/// A hex quantity (0x-prefixed, as eth_getBalance answers) as a decimal string; None when it is not one.
pub fn wei_from_hex(hex: &str) -> Option<String> {
let h = hex.trim().strip_prefix("0x").unwrap_or(hex.trim());
if h.is_empty() {
return Some("0".into());
}
u128::from_str_radix(h, 16).ok().map(|v| v.to_string())
}
/// Miner UI 4 (6 October 2026): a card's own goal when set, else the global one. /// Miner UI 4 (6 October 2026): a card's own goal when set, else the global one.
pub fn goal_for(card_goal: &str, global: &str) -> Goal { pub fn goal_for(card_goal: &str, global: &str) -> Goal {
Goal::parse(if card_goal.trim().is_empty() { global } else { card_goal }) Goal::parse(if card_goal.trim().is_empty() { global } else { card_goal })
@ -1034,6 +1049,20 @@ mod tests {
assert!(Run::applied(&step, Readback { limit_w: 90.0, acked: false }, 100.0)); assert!(Run::applied(&step, Readback { limit_w: 90.0, acked: false }, 100.0));
} }
#[test]
fn the_fleet_draw_sums_mining_cards_with_a_fresh_reading_and_the_balance_reads_in_wei() {
let now = 1_791_300_000.0;
let cards = vec![("mining", 226.8, now - 5.0), ("mining", 75.6, now - 10.0), ("mining", 201.0, now - 120.0), ("off", 30.0, now - 1.0), ("mining", 0.0, now)];
let w = fleet_watts(cards.iter().map(|(s, w, at)| (*s, *w, *at)), now, 60.0);
assert!((w - 302.4).abs() < 1e-9, "the stale 9070 XT reading and the card that is off do not count: {w}");
assert!((pounds_per_day(302.4, 28.5) - 2.068416).abs() < 1e-6);
assert_eq!(wei_from_hex("0x1a"), Some("26".into()));
assert_eq!(wei_from_hex("0x0"), Some("0".into()));
assert_eq!(wei_from_hex("0x"), Some("0".into()));
assert_eq!(wei_from_hex("0xde0b6b3a7640000"), Some("1000000000000000000".into()), "one IGN");
assert_eq!(wei_from_hex("soon"), None);
}
#[test] #[test]
fn a_card_goal_overrides_the_global_goal_and_empty_follows_it() { fn a_card_goal_overrides_the_global_goal_and_empty_follows_it() {
assert_eq!(goal_for("", "balanced"), Goal::Balanced); assert_eq!(goal_for("", "balanced"), Goal::Balanced);

View file

@ -28,6 +28,8 @@ pub struct CardChoice {
pub enum Cmd { pub enum Cmd {
Detect, Detect,
/// the payout address's balance (eth_getBalance, hex) or why it could not be read
BalanceRead(Result<String, String>),
ApplyCards(Vec<CardChoice>), ApplyCards(Vec<CardChoice>),
/// one enumeration of the cards finished (the first, or a re-detection: src/hotplug.rs) /// one enumeration of the cards finished (the first, or a re-detection: src/hotplug.rs)
Detected(crate::detect::Detection), Detected(crate::detect::Detection),
@ -404,6 +406,10 @@ fn address_state(s: &Settings, wallet_path: &std::path::Path) -> crate::state::A
source: s.address_source.clone(), source: s.address_source.clone(),
key_saved: s.key_saved, key_saved: s.key_saved,
wallet_file: if s.address_source == "generated" { wallet_path.display().to_string() } else { String::new() }, wallet_file: if s.address_source == "generated" { wallet_path.display().to_string() } else { String::new() },
balance_wei: None,
balance_age_s: -1.0,
balance_note: String::new(),
price_gbp_per_ign: None,
} }
} }
@ -610,6 +616,10 @@ pub struct Engine {
tune_acked: Option<u64>, tune_acked: Option<u64>,
/// cards whose confirm check found a better neighbour: the full plan runs next /// cards whose confirm check found a better neighbour: the full plan runs next
tune_full_due: std::collections::HashSet<usize>, tune_full_due: std::collections::HashSet<usize>,
/// Miner UI 4: the balance read (every 30 s while the node runs; 60 s after a failure)
balance_next: Option<Instant>,
balance_busy: bool,
balance_at: f64,
/// a sweep waiting for the cap-mode probe: (card index, forced) /// a sweep waiting for the cap-mode probe: (card index, forced)
sweep_pending: Option<(usize, bool)>, sweep_pending: Option<(usize, bool)>,
/// how caps are set: Some(true) directly, Some(false) through the elevated helper, None = not probed yet /// how caps are set: Some(true) directly, Some(false) through the elevated helper, None = not probed yet
@ -714,6 +724,9 @@ impl Engine {
sweep_helper_is_task: false, sweep_helper_is_task: false,
tune_acked: None, tune_acked: None,
tune_full_due: std::collections::HashSet::new(), tune_full_due: std::collections::HashSet::new(),
balance_next: None,
balance_busy: false,
balance_at: 0.0,
sweep_pending: None, sweep_pending: None,
sweep_direct: None, sweep_direct: None,
sweep_helper: false, sweep_helper: false,
@ -1149,6 +1162,29 @@ impl Engine {
} }
} }
Cmd::TuneProgress(v) => self.tune_progress(&v), Cmd::TuneProgress(v) => self.tune_progress(&v),
Cmd::BalanceRead(r) => {
self.balance_busy = false;
match r {
Ok(hex) => match crate::ember::wei_from_hex(&hex) {
Some(wei) => {
let mut st = self.st();
st.address.balance_wei = Some(wei);
st.address.balance_note.clear();
drop(st);
self.balance_at = crate::platform::unix_now_f();
self.balance_next = Some(Instant::now() + Duration::from_secs(30));
}
None => {
self.st().address.balance_note = format!("the node answered {hex} for the balance, not a quantity");
self.balance_next = Some(Instant::now() + Duration::from_secs(60));
}
},
Err(e) => {
self.st().address.balance_note = e;
self.balance_next = Some(Instant::now() + Duration::from_secs(60));
}
}
}
Cmd::TuneCardGoal(key, goal) => { Cmd::TuneCardGoal(key, goal) => {
{ {
let mut s = self.shared.settings.lock().unwrap(); let mut s = self.shared.settings.lock().unwrap();
@ -3008,6 +3044,7 @@ impl Engine {
self.tick_sweep(now); self.tick_sweep(now);
} }
self.derive(now); self.derive(now);
self.tick_balance(now);
if now.duration_since(self.last_status) >= Duration::from_secs(self.shared.runtime.status_secs as u64) { if now.duration_since(self.last_status) >= Duration::from_secs(self.shared.runtime.status_secs as u64) {
self.last_status = now; self.last_status = now;
if self.running { if self.running {
@ -3544,6 +3581,28 @@ impl Engine {
} }
} }
/// Miner UI 4: the payout address's balance through the node's own RPC, every 30 s while the node answers.
fn tick_balance(&mut self, now: Instant) {
if self.balance_busy || self.balance_next.map(|t| now < t).unwrap_or(false) {
return;
}
let (addr, node_up) = {
let st = self.st();
(st.address.value.clone(), matches!(st.node.state.as_str(), "syncing" | "synced"))
};
if addr.len() != 42 || !node_up {
self.balance_next = Some(now + Duration::from_secs(10));
return;
}
self.balance_busy = true;
self.balance_next = Some(now + Duration::from_secs(30));
let shared = self.shared.clone();
std::thread::spawn(move || {
let r = crate::prover::evm_rpc(&shared, "eth_getBalance", serde_json::json!([addr, "latest"]), Duration::from_secs(8)).and_then(|v| v.as_str().map(|s| s.to_string()).ok_or_else(|| "eth_getBalance: not a string".to_string()));
shared.send(Cmd::BalanceRead(r));
});
}
/// Derived fields: ages, the hash total, the program countdown, the finality age, the mining state word. /// Derived fields: ages, the hash total, the program countdown, the finality age, the mining state word.
fn derive(&mut self, now: Instant) { fn derive(&mut self, now: Instant) {
let unix = crate::platform::unix_now_f(); let unix = crate::platform::unix_now_f();
@ -3558,6 +3617,10 @@ impl Engine {
c.eff_mhw = if c.state == "mining" && c.power_w > 1.0 && c.hash_now > 0.0 && unix - c.telemetry_at < 60.0 { c.hash_now / c.power_w } else { 0.0 }; c.eff_mhw = if c.state == "mining" && c.power_w > 1.0 && c.hash_now > 0.0 && unix - c.telemetry_at < 60.0 { c.hash_now / c.power_w } else { 0.0 };
} }
st.mining.hash_total = total; st.mining.hash_total = total;
// Miner UI 4: the fleet's draw and its £ a day (settings.power_price_pence; 0 when no price is set)
st.mining.watts_total = crate::ember::fleet_watts(st.mining.cards.iter().map(|c| (c.state.as_str(), c.power_w, c.telemetry_at)), unix, 60.0);
st.mining.pounds_per_day = if st.settings.power_price_pence > 0.0 { crate::ember::pounds_per_day(st.mining.watts_total, st.settings.power_price_pence) } else { 0.0 };
st.address.balance_age_s = if self.balance_at > 0.0 { unix - self.balance_at } else { -1.0 };
crate::hotplug::age(&mut st.mining.cards, unix); crate::hotplug::age(&mut st.mining.cards, unix);
let cut = unix - 3600.0; let cut = unix - 3600.0;
st.mining.found.retain(|t| *t > cut); st.mining.found.retain(|t| *t > cut);

View file

@ -1,20 +1,36 @@
//! GET /api/live for the dashboard's chain scene (ui/live-dag.js, the site's module). The scene wants the network's //! GET /api/live for the dashboard's chain scene (ui/live-dag.js, the site's module) and the Cards tab's network
//! blocks over the last minutes with lanes, parents, colours, the selected chain, checkpoints and proof state. The //! numbers. Two sources, one contract:
//! local node speaks gRPC and wRPC only and the engine carries no client for either (no TLS, no websocket stack), so //!
//! this reads the observer's /api/live (the same URL ota.rs already polls for the identity count) through curl, caches //! * the observer's /api/live (the same URL ota.rs polls for the identity count), read through curl, cached 2 s per
//! it for 2 s, and marks this machine's blocks: the observer names a block's miner by the first 8 hex of its vote key //! window, with this machine's blocks marked as the lane "you" (`shape`): the scene's full feed (lanes, parents,
//! hash, and every card's `ids` carry the same 8 characters for this machine's keys, so a match becomes the lane "you". //! colours, the selected chain, checkpoints, proof state). `state.source` = "observer".
//! Read-only; one upstream call per 2 s whatever the window asks. A local-node source is owed (a JSON RPC on igneumd or //! * the local node's `igneum_getRecentBlocks(seconds)` (igneumd 0.3.17, the node lane's eec34ac3): the last 600 s
//! a wRPC client here); the shape stays the same when it comes. //! of chain and merged blocks with vote key hashes, blue scores and colours. The engine computes the observer's
//! state fields from it (`shape_from_blocks`: miners_10m, blocks_10m, blocks_per_minute) and adds them to every
//! observer reply as `state.node`, so the Cards tab reads network numbers that need no site; and when the observer
//! is unreachable the node's rows stand in for the scene (`state.source` = "node", `partial: true`: no parents, no
//! proof state), instead of `{ok:false}`. A node before 0.3.17 answers -32601 and the node source rests 10 minutes.
//!
//! Read-only; one observer call per 2 s whatever the window asks; one node call per 5 s at most.
use crate::engine::Shared;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::collections::HashSet; use std::collections::HashSet;
use std::process::Command; use std::process::Command;
use std::sync::Mutex; use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
static CACHE: Mutex<Option<(Instant, u32, Value)>> = Mutex::new(None); static CACHE: Mutex<Option<(Instant, u32, Value)>> = Mutex::new(None);
const TTL: Duration = Duration::from_secs(2); const TTL: Duration = Duration::from_secs(2);
struct NodeCache {
at: Option<Instant>,
blocks: Vec<RecentBlock>,
/// the node said "method not found" (-32601): rest until then
retry: Option<Instant>,
}
static NODE: Mutex<NodeCache> = Mutex::new(NodeCache { at: None, blocks: Vec::new(), retry: None });
const NODE_TTL: Duration = Duration::from_secs(5);
/// The observer's reply with this machine's lane marked. `ids` are the 8-character vote key ids of this machine's /// The observer's reply with this machine's lane marked. `ids` are the 8-character vote key ids of this machine's
/// cards. Blocks whose `miner` is one of them read `miner: "you"`; `state.you_blocks` counts them; `state.source` names /// cards. Blocks whose `miner` is one of them read `miner: "you"`; `state.you_blocks` counts them; `state.source` names
/// where the data came from. Anything else passes through untouched, so the module's contract holds. /// where the data came from. Anything else passes through untouched, so the module's contract holds.
@ -25,7 +41,9 @@ pub fn shape(mut reply: Value, ids: &HashSet<String>) -> Value {
let mine = b.get("miner").and_then(|m| m.as_str()).map(|m| ids.contains(m)).unwrap_or(false); let mine = b.get("miner").and_then(|m| m.as_str()).map(|m| ids.contains(m)).unwrap_or(false);
if mine { if mine {
yours += 1; yours += 1;
if let Some(o) = b.as_object_mut() { o.insert("miner".into(), json!("you")); } if let Some(o) = b.as_object_mut() {
o.insert("miner".into(), json!("you"));
}
} }
} }
} }
@ -41,30 +59,197 @@ pub fn url_for(live_api: &str, window_s: u32) -> String {
format!("{live_api}?window={}", window_s.clamp(30, 300)) format!("{live_api}?window={}", window_s.clamp(30, 300))
} }
/// The reply for the dashboard: cached 2 s per window; `{ok:false, error}` when the observer is unreachable or the /// One block from `igneum_getRecentBlocks`.
/// build has no observer address (the UI then draws its own blocks strip). #[derive(Clone, Debug)]
pub fn fetch(live_api: &str, window_s: u32, ids: &HashSet<String>) -> Value { pub struct RecentBlock {
if live_api.is_empty() { pub hash: String,
return json!({ "ok": false, "error": "no observer address in this build" }); pub blue_score: u64,
pub daa_score: u64,
pub timestamp_ms: u64,
pub vote_key_hash: String,
pub is_chain_block: bool,
pub color: String,
/// "header" or "chain_block" (eec34ac3: a merged block whose own header was not at hand carries its merging
/// chain block's time and blue score, marked so blocks_per_minute stays honest); "" on an older row = header
pub timestamp_source: String,
}
pub fn parse_recent(v: &Value) -> Vec<RecentBlock> {
let s = |b: &Value, k: &str| b.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
let n = |b: &Value, k: &str| b.get(k).and_then(|x| x.as_u64()).unwrap_or(0);
v.as_array()
.map(|a| {
a.iter()
.map(|b| RecentBlock {
hash: s(b, "hash"),
blue_score: n(b, "blue_score"),
daa_score: n(b, "daa_score"),
timestamp_ms: n(b, "timestamp_ms"),
vote_key_hash: s(b, "vote_key_hash"),
is_chain_block: b.get("is_chain_block").and_then(|x| x.as_bool()).unwrap_or(false),
color: s(b, "color"),
timestamp_source: s(b, "timestamp_source"),
})
.collect()
})
.unwrap_or_default()
}
/// The lane id the UI matches against a card's `ids`: the first 8 hex of the vote key hash (engine.rs gives a card
/// its ids the same way, `h.chars().take(8)`); "" when the block carries no key.
pub fn lane(vote_key_hash: &str) -> String {
vote_key_hash.trim_start_matches("0x").chars().take(8).collect()
}
/// The site's `short`: the first 16 hex (site/api/live.mjs), so a hash reads the same from either source.
fn short(h: &str) -> String {
h.trim_start_matches("0x").chars().take(16).collect()
}
/// The observer's `state` fields that come from the blocks: distinct vote keys in 10 minutes, blocks in 10
/// minutes, blocks per minute over the last 10 minutes (oldest first), and the `blocks` (last `window_s`, in the
/// scene's row shape as far as the node knows it: no parents) and `miners` arrays. `now_ms` is the reference time.
pub fn shape_from_blocks(blocks: &[RecentBlock], now_ms: u64, window_s: u64) -> Value {
let ten = now_ms.saturating_sub(600_000);
let recent: Vec<&RecentBlock> = blocks.iter().filter(|b| b.timestamp_ms >= ten).collect();
let mut miners: std::collections::BTreeMap<&str, u64> = Default::default();
for b in &recent {
if !b.vote_key_hash.is_empty() {
*miners.entry(b.vote_key_hash.as_str()).or_insert(0) += 1;
}
} }
let mut per_min = vec![0u64; 10];
for b in &recent {
let idx = ((b.timestamp_ms - ten) / 60_000).min(9) as usize;
per_min[idx] += 1;
}
let win = now_ms.saturating_sub(window_s * 1000);
let rows: Vec<Value> = blocks
.iter()
.filter(|b| b.timestamp_ms >= win)
.map(|b| {
json!({
"hash": short(&b.hash), "ts": b.timestamp_ms, "timestamp_ms": b.timestamp_ms, "blue_score": b.blue_score, "daa": b.daa_score,
"parents": [], "chain": b.is_chain_block, "is_chain_block": b.is_chain_block,
"color": if b.color.is_empty() { "pending" } else { b.color.as_str() },
"miner": lane(&b.vote_key_hash), "vote_key_hash": short(&b.vote_key_hash),
"timestamp_source": if b.timestamp_source.is_empty() { "header" } else { b.timestamp_source.as_str() },
"locked": false, "final": false, "shards": [], "proven": false
})
})
.collect();
json!({
"miners_10m": miners.len(),
"blocks_10m": recent.len(),
"blocks_per_minute": per_min,
"blocks": rows,
"miners": miners.iter().map(|(k, n)| json!({ "id": lane(k), "vote_key_hash": short(k), "blocks_10m": n })).collect::<Vec<_>>(),
})
}
/// The node's recent blocks, cached 5 s; empty when the node is down, carries no such method (-32601, rests 10
/// minutes) or answered nothing.
fn node_blocks(shared: &Arc<Shared>) -> Vec<RecentBlock> {
{
let c = NODE.lock().unwrap();
if c.at.map(|t| t.elapsed() < NODE_TTL).unwrap_or(false) {
return c.blocks.clone();
}
if c.retry.map(|t| Instant::now() < t).unwrap_or(false) {
return Vec::new();
}
}
let node_up = matches!(shared.state.lock().unwrap().node.state.as_str(), "syncing" | "synced");
if !node_up {
return Vec::new();
}
match crate::prover::evm_rpc(shared, "igneum_getRecentBlocks", json!([600]), Duration::from_secs(6)) {
Ok(v) if v.is_array() => {
let blocks = parse_recent(&v);
let mut c = NODE.lock().unwrap();
c.at = Some(Instant::now());
c.blocks = blocks.clone();
blocks
}
Ok(_) => Vec::new(),
Err(e) => {
// the node's default arm: {"code": -32601, "message": "method igneum_getRecentBlocks not found"}
if e.contains("not found") || e.contains("-32601") {
NODE.lock().unwrap().retry = Some(Instant::now() + Duration::from_secs(600));
}
Vec::new()
}
}
}
/// The node's `state` fields for the Cards tab (`state.node` on an observer reply, `state` on a node-only one).
fn node_state(shared: &Arc<Shared>, blocks: &[RecentBlock], now_ms: u64) -> Value {
let mut v = shape_from_blocks(blocks, now_ms, 90);
let st = shared.state.lock().unwrap();
let o = v.as_object_mut().unwrap();
o.remove("blocks");
o.remove("miners");
o.insert("block_count".into(), json!(st.node.blocks));
o.insert("header_count".into(), json!(st.node.headers));
o.insert("daa".into(), json!(st.node.daa));
o.insert("blue_score".into(), json!(st.node.blue));
o.insert("difficulty".into(), json!(st.node.difficulty));
o.insert("peers".into(), json!(st.node.peers));
o.insert("synced".into(), json!(st.node.synced));
o.insert("hashes_per_second_estimate".into(), Value::Null);
o.insert("source".into(), json!("node"));
v
}
/// The reply for the dashboard: the observer's, cached 2 s per window, with the node's state fields added as
/// `state.node` when the node answers; the node's rows alone (`partial: true`) when the observer is unreachable;
/// `{ok:false, error}` when neither answers (the UI then draws its own blocks strip).
pub fn fetch(shared: &Arc<Shared>, live_api: &str, window_s: u32, ids: &HashSet<String>) -> Value {
let window_s = window_s.clamp(30, 300); let window_s = window_s.clamp(30, 300);
if let Some((at, w, v)) = CACHE.lock().unwrap().as_ref() { let now_ms = (crate::platform::unix_now_f() * 1000.0) as u64;
if *w == window_s && at.elapsed() < TTL { return v.clone(); } let blocks = node_blocks(shared);
} let node = if blocks.is_empty() { None } else { Some(node_state(shared, &blocks, now_ms)) };
let url = url_for(live_api, window_s); let cached = CACHE.lock().unwrap().as_ref().and_then(|(at, w, v)| if *w == window_s && at.elapsed() < TTL { Some(v.clone()) } else { None });
let out = crate::detect::run_timeout( let mut reply = match cached {
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "4", &url]), Some(v) => v,
None, None if live_api.is_empty() => json!({ "ok": false, "error": "no observer address in this build" }),
Duration::from_secs(6), None => {
); let url = url_for(live_api, window_s);
let reply = match out.and_then(|t| serde_json::from_str::<Value>(&t).ok()) { let out = crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "4", &url]), None, Duration::from_secs(6));
Some(v) if v.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) && v.get("blocks").map(|b| b.is_array()).unwrap_or(false) => shape(v, ids), let reply = match out.and_then(|t| serde_json::from_str::<Value>(&t).ok()) {
_ => json!({ "ok": false, "error": "the observer did not answer" }), Some(v) if v.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) && v.get("blocks").map(|b| b.is_array()).unwrap_or(false) => shape(v, ids),
_ => json!({ "ok": false, "error": "the observer did not answer" }),
};
if reply.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) {
*CACHE.lock().unwrap() = Some((Instant::now(), window_s, reply.clone()));
}
reply
}
}; };
if reply.get("ok").and_then(|o| o.as_bool()).unwrap_or(false) { let observer_ok = reply.get("ok").and_then(|o| o.as_bool()).unwrap_or(false);
*CACHE.lock().unwrap() = Some((Instant::now(), window_s, reply.clone())); match (observer_ok, node) {
(true, Some(n)) => {
if let Some(st) = reply.get_mut("state").and_then(|s| s.as_object_mut()) {
st.insert("node".into(), n);
}
reply
}
(true, None) => reply,
(false, Some(n)) => {
// the node's rows stand in: the scene gets blocks without parents or proof state, and says so
let mut v = shape_from_blocks(&blocks, now_ms, window_s as u64);
let o = v.as_object_mut().unwrap();
let rows = o.remove("blocks").unwrap_or(Value::Null);
let miners = o.remove("miners").unwrap_or(Value::Null);
let mut out = json!({ "ok": true, "partial": true, "now": crate::platform::unix_now_f(), "state": n, "blocks": rows, "miners": miners, "events": [], "finality": { "checkpoints": [] } });
out = shape(out, ids);
if let Some(st) = out.get_mut("state").and_then(|s| s.as_object_mut()) {
st.insert("source".into(), json!("node"));
}
out
}
(false, None) => reply,
} }
reply
} }
#[cfg(test)] #[cfg(test)]
@ -131,10 +316,51 @@ mod tests {
assert_eq!(url_for("https://igneum.network/api/live", 9000), "https://igneum.network/api/live?window=300"); assert_eq!(url_for("https://igneum.network/api/live", 9000), "https://igneum.network/api/live?window=300");
} }
fn b(ts: u64, key: &str, chain: bool) -> RecentBlock {
RecentBlock { hash: format!("0x{:064x}", ts), blue_score: ts / 1000, daa_score: ts / 1000, timestamp_ms: ts, vote_key_hash: key.into(), is_chain_block: chain, color: if chain { "blue".into() } else { String::new() }, timestamp_source: String::new() }
}
#[test] #[test]
fn a_build_without_an_observer_address_says_so() { fn the_state_fields_come_from_the_nodes_blocks_of_the_last_ten_minutes() {
let out = fetch("", 120, &HashSet::new()); let now = 1_791_300_000_000u64;
assert_eq!(out["ok"], false); let blocks = vec![b(now - 5_000, "aa", true), b(now - 30_000, "bb", true), b(now - 95_000, "aa", false), b(now - 500_000, "cc", true), b(now - 700_000, "dd", true)];
assert_eq!(out["error"], "no observer address in this build"); let v = shape_from_blocks(&blocks, now, 120);
assert_eq!(v["miners_10m"], 3, "dd is older than 10 minutes");
assert_eq!(v["blocks_10m"], 4);
let pm = v["blocks_per_minute"].as_array().unwrap();
assert_eq!(pm.len(), 10);
assert_eq!(pm[9], 2, "the newest minute holds the 5 s and 30 s blocks");
assert_eq!(pm[8], 1, "the 95 s block");
assert_eq!(pm[1], 1, "the 500 s block");
assert_eq!(v["blocks"].as_array().unwrap().len(), 3, "the 120 s window");
assert_eq!(v["blocks"][2]["color"], "pending", "an unmerged block without a colour");
assert_eq!(v["blocks"][0]["color"], "blue");
assert_eq!(v["blocks"][0]["timestamp_source"], "header");
assert_eq!(v["blocks"][0]["miner"], "aa", "the lane id is the first 8 hex of the vote key hash, as a card's ids");
assert_eq!(v["blocks"][0]["chain"], true, "the scene's row shape");
assert_eq!(v["blocks"][0]["parents"], json!([]), "the node method carries no parents");
assert_eq!(v["miners"].as_array().unwrap().len(), 3);
assert_eq!(v["miners"][0]["id"], "aa", "the miners rows carry id, as the site's");
// the lane "you" marks node rows too
let ids: HashSet<String> = ["aa".to_string()].into_iter().collect();
let marked = shape(json!({ "ok": true, "state": {}, "blocks": v["blocks"] }), &ids);
assert_eq!(marked["blocks"][0]["miner"], "you");
assert_eq!(marked["state"]["you_blocks"], 2);
assert_eq!(lane("0x0123456789abcdef"), "01234567");
assert_eq!(lane(""), "");
}
#[test]
fn recent_blocks_parse_from_the_node_reply_and_an_empty_reply_is_empty() {
let v: Value = serde_json::from_str(r#"[{"hash":"0xab","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":"cd","is_chain_block":true,"color":"blue"},{"hash":"0xcd","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":"ef","is_chain_block":false,"color":"red","timestamp_source":"chain_block"}]"#).unwrap();
let p = parse_recent(&v);
assert_eq!(p.len(), 2);
assert_eq!((p[0].blue_score, p[0].is_chain_block, p[0].color.as_str()), (5, true, "blue"));
assert_eq!((p[1].color.as_str(), p[1].timestamp_source.as_str()), ("red", "chain_block"));
// a null vote_key_hash (a chain block with no mergeset entry, which the executor never produces) reads as ""
let n: Value = serde_json::from_str(r#"[{"hash":"0xab","blue_score":5,"daa_score":6,"timestamp_ms":7,"vote_key_hash":null,"is_chain_block":true,"color":"blue","timestamp_source":"header"}]"#).unwrap();
assert_eq!(parse_recent(&n)[0].vote_key_hash, "");
assert!(parse_recent(&json!(null)).is_empty());
assert_eq!(short("0x1234567890abcdef00"), "1234567890abcdef", "the site's 16-hex short");
} }
} }

View file

@ -167,7 +167,7 @@ fn exec_boundary(shared: &Shared) -> u64 {
text.strip_prefix("restart at chain block ").and_then(|t| t.split_whitespace().next()).and_then(|t| t.parse().ok()).unwrap_or(0) text.strip_prefix("restart at chain block ").and_then(|t| t.split_whitespace().next()).and_then(|t| t.parse().ok()).unwrap_or(0)
} }
fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> { pub(crate) fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string(); let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
let tmp = std::env::temp_dir().join(format!("igneum-prover-{}-{}.json", std::process::id(), method)); let tmp = std::env::temp_dir().join(format!("igneum-prover-{}-{}.json", std::process::id(), method));
std::fs::write(&tmp, body).map_err(|e| e.to_string())?; std::fs::write(&tmp, body).map_err(|e| e.to_string())?;

View file

@ -185,7 +185,7 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
let window = query_param(&req.query, "window").and_then(|s| s.parse().ok()).unwrap_or(120u32); let window = query_param(&req.query, "window").and_then(|s| s.parse().ok()).unwrap_or(120u32);
let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page)); let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page));
let ids: std::collections::HashSet<String> = shared.state.lock().unwrap().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect(); let ids: std::collections::HashSet<String> = shared.state.lock().unwrap().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect();
json_resp(&mut stream, 200, crate::live::fetch(&live_api, window, &ids)); json_resp(&mut stream, 200, crate::live::fetch(&shared, &live_api, window, &ids));
} }
("GET", "/api/log") => { ("GET", "/api/log") => {
let after = query_param(&req.query, "after").and_then(|s| s.parse().ok()).unwrap_or(0u64); let after = query_param(&req.query, "after").and_then(|s| s.parse().ok()).unwrap_or(0u64);

View file

@ -167,6 +167,10 @@ pub struct MiningState {
/// dev-fee blocks (the miner's `dev-fee block` lines): this run, and lifetime /// dev-fee blocks (the miner's `dev-fee block` lines): this run, and lifetime
pub fee_session: u64, pub fee_session: u64,
pub fee_total: u64, pub fee_total: u64,
/// Miner UI 4 (6 October 2026): the sum of the mining cards' draw (a reading under 60 s old), and that draw as
/// £ a day at settings.power_price_pence (0 when no price is set)
pub watts_total: f64,
pub pounds_per_day: f64,
} }
/// The miner software's dev fee as the miner reports it at start (`dev fee 1% (1 block in 100) to 0x...`). /// The miner software's dev fee as the miner reports it at start (`dev fee 1% (1 block in 100) to 0x...`).
@ -275,6 +279,15 @@ pub struct AddressState {
pub source: String, pub source: String,
pub key_saved: bool, pub key_saved: bool,
pub wallet_file: String, pub wallet_file: String,
/// Miner UI 4: the payout address's balance in wei as a decimal string (eth_getBalance through the node's own
/// RPC, read every 30 s while the node runs); null until the first read; balance_age_s = -1 until then
pub balance_wei: Option<String>,
pub balance_age_s: f64,
pub balance_note: String, // the last read's error, in words; "" when the last read was good
/// £ per IGN. null until a market exists. Its one source will be a SIGNED field of the OTA manifest (`price`:
/// gbp_per_ign, as_of, source), checked like the manifest's tuning object; the app never computes or fetches a
/// price on its own
pub price_gbp_per_ign: Option<f64>,
} }
#[derive(Clone, Serialize, Default)] #[derive(Clone, Serialize, Default)]

View file

@ -318,6 +318,16 @@ Correction (6 October 2026, 19:0xZ): release-0.3.15 took ember-tune at 5429e82 (
Cards tab ships in 0.3.15 and reads the per-card goal and the before fields; so the row below is IN 0.3.15 except Cards tab ships in 0.3.15 and reads the per-card goal and the before fields; so the row below is IN 0.3.15 except
`card.tune_floor` (995530b), which rides the next cut. `card.tune_floor` (995530b), which rides the next cut.
For 0.3.16 (main, 6 October 2026 evening), the engine fields Miner UI 4 reads, on ember-tune past 5429e82:
| Field | What | Where |
|---|---|---|
| `state.mining.watts_total` | the sum of the mining cards' draw (a reading under 60 s old) | `ember::fleet_watts`, engine `derive` + test |
| `state.mining.pounds_per_day` | that draw as £ a day at `settings.power_price_pence` (0 when no price) | `ember::pounds_per_day` |
| `state.address.balance_wei` | the payout address's balance in wei as a decimal string, `eth_getBalance` through the node's own RPC every 30 s while the node runs (60 s after a failure); null until read; `balance_age_s` (-1 until then), `balance_note` (the last error in words) | engine `tick_balance`, `Cmd::BalanceRead`, `ember::wei_from_hex` + test |
| `state.address.price_gbp_per_ign` | null. Its one source will be a SIGNED field of the OTA manifest (`price`: gbp_per_ign, as_of, source), checked like the tuning object; the app never computes or fetches a price itself | state.rs (documented), no code until a market exists |
| `GET /api/live` | the observer's reply shape from a local source: `"source": "node"` when igneumd carries `igneum_getRecentBlocks(seconds)` (the node lane, a283f5f0d364ceef0; the engine computes miners_10m, blocks_10m, blocks_per_minute, the 90 s blocks and the miners list from it and takes the DAG numbers from its node state), else `"source": "site"` (the public reply, fetched by curl, cached 60 s) with `age_s`; `pending: true` before the first fetch | src/live.rs (`shape_from_blocks`, `parse_recent` + tests), server.rs |
For Miner UI 4's Cards tab (Ember as its second layer), on ember-tune: For Miner UI 4's Cards tab (Ember as its second layer), on ember-tune:
| Commit | What | Where | | Commit | What | Where |