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>
This commit is contained in:
igneum-labs 2026-10-06 19:24:38 +00:00
parent e436c3c55c
commit 73c3794e76
9 changed files with 345 additions and 2 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)")
}
/// 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.
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);

View file

@ -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<String, String>),
ApplyCards(Vec<CardChoice>),
/// 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<u64>,
/// cards whose confirm check found a better neighbour: the full plan runs next
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)
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 {
@ -3523,6 +3560,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();
@ -3537,6 +3596,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);

221
app/igneum-app/src/live.rs Normal file
View file

@ -0,0 +1,221 @@
//! GET /api/live on the engine (Miner UI 4, 6 October 2026): the observer's reply shape from a local source.
//!
//! Source "node": igneumd's `igneum_getRecentBlocks(seconds)` (0.3.16, the node lane) gives the last 600 s of
//! blocks with vote key hashes, blue scores and colours; the state fields are computed here (`shape_from_blocks`)
//! and the DAG numbers come from the node state the engine already holds. Source "site": until the node carries
//! the method (or when it fails), the public site's /api/live reply, fetched by curl and cached 60 s, with
//! `age_s`. Either way the reply carries `source`, and a reply that is not ready yet says `pending: true`.
use crate::engine::Shared;
use serde_json::{json, Value};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
struct Cache {
body: Option<Value>,
at: Option<Instant>,
source: &'static str,
busy: bool,
/// the node said "method not found": do not ask again before this
node_retry: Option<Instant>,
}
fn cache() -> &'static Mutex<Cache> {
static C: OnceLock<Mutex<Cache>> = OnceLock::new();
C.get_or_init(|| Mutex::new(Cache { body: None, at: None, source: "site", busy: false, node_retry: None }))
}
pub const FRESH_S: u64 = 60;
/// 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,
}
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") })
.collect()
})
.unwrap_or_default()
}
/// 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`) 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), "blue_score": b.blue_score, "daa": b.daa_score, "timestamp_ms": b.timestamp_ms, "is_chain_block": b.is_chain_block, "color": if b.color.is_empty() { "pending" } else { b.color.as_str() }, "vote_key_hash": short(&b.vote_key_hash) }))
.collect();
json!({
"miners_10m": miners.len(),
"blocks_10m": recent.len(),
"blocks_per_minute": per_min,
"blocks": rows,
"miners": miners.iter().map(|(k, n)| json!({ "vote_key_hash": short(k), "blocks_10m": n })).collect::<Vec<_>>(),
})
}
fn short(h: &str) -> String {
let h = h.trim_start_matches("0x");
if h.len() > 12 {
format!("{}..{}", &h[..6], &h[h.len() - 6..])
} else {
h.to_string()
}
}
/// The reply for GET /api/live: cached when fresh, else a refresh is started and the last body (or `pending`)
/// comes back with its age.
pub fn reply(shared: &std::sync::Arc<Shared>) -> Value {
let now = Instant::now();
let (body, at, source, kick) = {
let mut c = cache().lock().unwrap();
let fresh = c.at.map(|t| now.duration_since(t) < Duration::from_secs(FRESH_S)).unwrap_or(false);
let kick = !fresh && !c.busy;
if kick {
c.busy = true;
}
(c.body.clone(), c.at, c.source, kick)
};
if kick {
let shared = shared.clone();
std::thread::spawn(move || refresh(&shared));
}
let age = at.map(|t| now.duration_since(t).as_secs_f64()).unwrap_or(-1.0);
match body {
Some(mut v) => {
if let Some(o) = v.as_object_mut() {
o.insert("source".into(), json!(source));
o.insert("age_s".into(), json!((age * 10.0).round() / 10.0));
o.insert("pending".into(), json!(false));
}
v
}
None => json!({ "ok": false, "pending": true, "source": source, "age_s": -1 }),
}
}
fn refresh(shared: &std::sync::Arc<Shared>) {
let got = from_node(shared).map(|v| (v, "node")).or_else(|| from_site(shared).map(|v| (v, "site")));
let mut c = cache().lock().unwrap();
c.busy = false;
if let Some((v, src)) = got {
c.body = Some(v);
c.at = Some(Instant::now());
c.source = src;
}
}
/// The node's own blocks, when it carries `igneum_getRecentBlocks`; None (and a 10-minute pause) when it does not.
fn from_node(shared: &std::sync::Arc<Shared>) -> Option<Value> {
{
let c = cache().lock().unwrap();
if c.node_retry.map(|t| Instant::now() < t).unwrap_or(false) {
return None;
}
}
let node_up = matches!(shared.state.lock().unwrap().node.state.as_str(), "syncing" | "synced");
if !node_up {
return None;
}
match crate::prover::evm_rpc(shared, "igneum_getRecentBlocks", json!([600]), Duration::from_secs(8)) {
Ok(v) if v.is_array() => {
let blocks = parse_recent(&v);
let now_ms = (crate::platform::unix_now_f() * 1000.0) as u64;
let mut shape = shape_from_blocks(&blocks, now_ms, 90);
let st = shared.state.lock().unwrap();
let state = json!({
"block_count": st.node.blocks, "header_count": st.node.headers, "daa": st.node.daa, "blue_score": st.node.blue,
"difficulty": st.node.difficulty, "peers": st.node.peers, "synced": st.node.synced,
"miners_10m": shape["miners_10m"], "blocks_10m": shape["blocks_10m"], "blocks_per_minute": shape["blocks_per_minute"],
"hashes_per_second_estimate": Value::Null,
});
let finality = json!({ "last_lock": st.finality.last_lock, "last_lock_at": st.finality.last_lock_at, "age_s": st.finality.age_s, "votes": st.finality.votes });
let o = shape.as_object_mut().unwrap();
let blocks_v = o.remove("blocks").unwrap_or(Value::Null);
let miners_v = o.remove("miners").unwrap_or(Value::Null);
Some(json!({ "ok": true, "now": crate::platform::unix_now_f(), "state": state, "blocks": blocks_v, "miners": miners_v, "events": [], "finality": finality }))
}
Ok(_) => None,
Err(e) => {
if e.contains("not found") || e.contains("-32601") || e.contains("Method") {
cache().lock().unwrap().node_retry = Some(Instant::now() + Duration::from_secs(600));
}
None
}
}
}
/// The public site's reply (the same fetch the OTA network guard makes).
fn from_site(shared: &std::sync::Arc<Shared>) -> Option<Value> {
let url = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page));
if url.is_empty() {
return None;
}
let out = crate::detect::run_timeout(std::process::Command::new(crate::platform::tool("curl")).args(["-fsS", "--max-time", "10", &url]), None, Duration::from_secs(12))?;
serde_json::from_str::<Value>(out.trim()).ok().filter(|v| v.is_object())
}
#[cfg(test)]
mod tests {
use super::*;
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() } }
}
#[test]
fn the_state_fields_come_from_the_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["miners"].as_array().unwrap().len(), 3);
}
#[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"}]"#).unwrap();
let p = parse_recent(&v);
assert_eq!(p.len(), 1);
assert_eq!((p[0].blue_score, p[0].is_chain_block, p[0].color.as_str()), (5, true, "blue"));
assert!(parse_recent(&json!(null)).is_empty());
assert_eq!(short("0x1234567890abcdef"), "123456..abcdef");
}
}

View file

@ -35,6 +35,7 @@ mod verifier;
mod wslhost;
mod sweep;
mod ember;
mod live;
mod powertask;
mod watchdog;

View file

@ -954,7 +954,7 @@ impl Updater {
// ---- the threads ---------------------------------------------------------------------------------------------------
/// https://igneum.network/live -> https://igneum.network/api/live; "" when the build carries no live page.
fn live_api_from(live_page: &str) -> String {
pub(crate) fn live_api_from(live_page: &str) -> String {
let Some(rest) = live_page.strip_prefix("https://") else { return String::new() };
let host = rest.split('/').next().unwrap_or("");
if host.is_empty() { String::new() } else { format!("https://{host}/api/live") }

View file

@ -132,7 +132,7 @@ struct Tools {
cuda: bool,
}
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 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())?;

View file

@ -178,6 +178,12 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
let v = shared.state_json();
json_resp(&mut stream, 200, v);
}
("GET", "/api/live") => {
// Miner UI 4: the observer's reply shape from the local node (igneum_getRecentBlocks) or the public
// site, with "source" and "age_s" (src/live.rs)
let v = crate::live::reply(&shared);
json_resp(&mut stream, 200, v);
}
("GET", "/api/log") => {
let after = query_param(&req.query, "after").and_then(|s| s.parse().ok()).unwrap_or(0u64);
let limit = query_param(&req.query, "limit").and_then(|s| s.parse().ok()).unwrap_or(400usize).min(2000);

View file

@ -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<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)]

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
`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 |