From 10c76fbd66242a4f85abca88adc45dcea8b35c74 Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Tue, 6 Oct 2026 19:24:38 +0000 Subject: [PATCH] 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 (cherry picked from commit 73c3794e768116bfb5dca8d8a17e862e0012afa1) --- app/igneum-app/src/ember.rs | 29 ++++ app/igneum-app/src/engine.rs | 63 ++++++++ app/igneum-app/src/live.rs | 294 +++++++++++++++++++++++++++++++---- app/igneum-app/src/prover.rs | 2 +- app/igneum-app/src/server.rs | 2 +- app/igneum-app/src/state.rs | 13 ++ docs/plans/ember-tune.md | 10 ++ 7 files changed, 377 insertions(+), 36 deletions(-) diff --git a/app/igneum-app/src/ember.rs b/app/igneum-app/src/ember.rs index 113751eff..bca7fe6e3 100644 --- a/app/igneum-app/src/ember.rs +++ b/app/igneum-app/src/ember.rs @@ -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)") } +/// 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, 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 { + 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. pub fn goal_for(card_goal: &str, global: &str) -> 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)); } + #[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] fn a_card_goal_overrides_the_global_goal_and_empty_follows_it() { assert_eq!(goal_for("", "balanced"), Goal::Balanced); diff --git a/app/igneum-app/src/engine.rs b/app/igneum-app/src/engine.rs index 86ff77edf..1e5b863f5 100644 --- a/app/igneum-app/src/engine.rs +++ b/app/igneum-app/src/engine.rs @@ -28,6 +28,8 @@ pub struct CardChoice { pub enum Cmd { Detect, + /// the payout address's balance (eth_getBalance, hex) or why it could not be read + BalanceRead(Result), ApplyCards(Vec), /// one enumeration of the cards finished (the first, or a re-detection: src/hotplug.rs) 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(), key_saved: s.key_saved, 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, /// cards whose confirm check found a better neighbour: the full plan runs next tune_full_due: std::collections::HashSet, + /// Miner UI 4: the balance read (every 30 s while the node runs; 60 s after a failure) + balance_next: Option, + balance_busy: bool, + balance_at: f64, /// a sweep waiting for the cap-mode probe: (card index, forced) sweep_pending: Option<(usize, bool)>, /// 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, tune_acked: None, tune_full_due: std::collections::HashSet::new(), + balance_next: None, + balance_busy: false, + balance_at: 0.0, sweep_pending: None, sweep_direct: None, sweep_helper: false, @@ -1149,6 +1162,29 @@ impl Engine { } } 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) => { { let mut s = self.shared.settings.lock().unwrap(); @@ -3008,6 +3044,7 @@ impl Engine { self.tick_sweep(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) { self.last_status = now; 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. fn derive(&mut self, now: Instant) { 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 }; } 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); let cut = unix - 3600.0; st.mining.found.retain(|t| *t > cut); diff --git a/app/igneum-app/src/live.rs b/app/igneum-app/src/live.rs index c1eab9367..b6615cef7 100644 --- a/app/igneum-app/src/live.rs +++ b/app/igneum-app/src/live.rs @@ -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 -//! blocks over the last minutes with lanes, parents, colours, the selected chain, checkpoints and proof state. The -//! 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 -//! 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 -//! hash, and every card's `ids` carry the same 8 characters for this machine's keys, so a match becomes the lane "you". -//! Read-only; one upstream call per 2 s whatever the window asks. A local-node source is owed (a JSON RPC on igneumd or -//! a wRPC client here); the shape stays the same when it comes. +//! GET /api/live for the dashboard's chain scene (ui/live-dag.js, the site's module) and the Cards tab's network +//! numbers. Two sources, one contract: +//! +//! * the observer's /api/live (the same URL ota.rs polls for the identity count), read through curl, cached 2 s per +//! window, with this machine's blocks marked as the lane "you" (`shape`): the scene's full feed (lanes, parents, +//! colours, the selected chain, checkpoints, proof state). `state.source` = "observer". +//! * the local node's `igneum_getRecentBlocks(seconds)` (igneumd 0.3.17, the node lane's eec34ac3): the last 600 s +//! 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 std::collections::HashSet; use std::process::Command; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; static CACHE: Mutex> = Mutex::new(None); const TTL: Duration = Duration::from_secs(2); +struct NodeCache { + at: Option, + blocks: Vec, + /// the node said "method not found" (-32601): rest until then + retry: Option, +} +static NODE: Mutex = 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 /// 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. @@ -25,7 +41,9 @@ pub fn shape(mut reply: Value, ids: &HashSet) -> Value { let mine = b.get("miner").and_then(|m| m.as_str()).map(|m| ids.contains(m)).unwrap_or(false); if mine { 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)) } -/// The reply for the dashboard: cached 2 s per window; `{ok:false, error}` when the observer is unreachable or the -/// build has no observer address (the UI then draws its own blocks strip). -pub fn fetch(live_api: &str, window_s: u32, ids: &HashSet) -> Value { - if live_api.is_empty() { - return json!({ "ok": false, "error": "no observer address in this build" }); +/// One block from `igneum_getRecentBlocks`. +#[derive(Clone, Debug)] +pub struct RecentBlock { + pub hash: String, + 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 { + 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 = 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::>(), + }) +} + +/// 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) -> Vec { + { + 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, 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, live_api: &str, window_s: u32, ids: &HashSet) -> Value { let window_s = window_s.clamp(30, 300); - if let Some((at, w, v)) = CACHE.lock().unwrap().as_ref() { - if *w == window_s && at.elapsed() < TTL { return v.clone(); } - } - let url = url_for(live_api, window_s); - let out = crate::detect::run_timeout( - Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "4", &url]), - None, - Duration::from_secs(6), - ); - let reply = match out.and_then(|t| serde_json::from_str::(&t).ok()) { - 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" }), + let now_ms = (crate::platform::unix_now_f() * 1000.0) as u64; + let blocks = node_blocks(shared); + let node = if blocks.is_empty() { None } else { Some(node_state(shared, &blocks, now_ms)) }; + 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 mut reply = match cached { + Some(v) => v, + None if live_api.is_empty() => json!({ "ok": false, "error": "no observer address in this build" }), + None => { + let url = url_for(live_api, window_s); + let out = crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "4", &url]), None, Duration::from_secs(6)); + let reply = match out.and_then(|t| serde_json::from_str::(&t).ok()) { + 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) { - *CACHE.lock().unwrap() = Some((Instant::now(), window_s, reply.clone())); + let observer_ok = reply.get("ok").and_then(|o| o.as_bool()).unwrap_or(false); + 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)] @@ -131,10 +316,51 @@ mod tests { 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] - fn a_build_without_an_observer_address_says_so() { - let out = fetch("", 120, &HashSet::new()); - assert_eq!(out["ok"], false); - assert_eq!(out["error"], "no observer address in this build"); + fn the_state_fields_come_from_the_nodes_blocks_of_the_last_ten_minutes() { + let now = 1_791_300_000_000u64; + 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)]; + 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 = ["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"); } } diff --git a/app/igneum-app/src/prover.rs b/app/igneum-app/src/prover.rs index 1d2140bbc..36c55542e 100644 --- a/app/igneum-app/src/prover.rs +++ b/app/igneum-app/src/prover.rs @@ -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) } -fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result { +pub(crate) fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result { 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)); std::fs::write(&tmp, body).map_err(|e| e.to_string())?; diff --git a/app/igneum-app/src/server.rs b/app/igneum-app/src/server.rs index 9db8176c7..81afdd798 100644 --- a/app/igneum-app/src/server.rs +++ b/app/igneum-app/src/server.rs @@ -185,7 +185,7 @@ fn handle(mut stream: TcpStream, shared: Arc) { 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 ids: std::collections::HashSet = 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") => { let after = query_param(&req.query, "after").and_then(|s| s.parse().ok()).unwrap_or(0u64); diff --git a/app/igneum-app/src/state.rs b/app/igneum-app/src/state.rs index ee090726f..bc5ed11e1 100644 --- a/app/igneum-app/src/state.rs +++ b/app/igneum-app/src/state.rs @@ -167,6 +167,10 @@ pub struct MiningState { /// dev-fee blocks (the miner's `dev-fee block` lines): this run, and lifetime pub fee_session: 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...`). @@ -275,6 +279,15 @@ pub struct AddressState { pub source: String, pub key_saved: bool, 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, + 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, } #[derive(Clone, Serialize, Default)] diff --git a/docs/plans/ember-tune.md b/docs/plans/ember-tune.md index 83737346b..0650c1cf5 100644 --- a/docs/plans/ember-tune.md +++ b/docs/plans/ember-tune.md @@ -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 `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: | Commit | What | Where |