miner-ui-5 (0.3.19): the ladder's engine half: ladder.json, api/ladder chain facts, the block card's PNG
src/ladder.rs: this machine's record (first block by card, hash joined to the miner's ACCEPTED line by nonce from the
node's PoW accepted line, blocks per UTC day, VOTE and LOCK lines as the signing record and streak, paid shards, the
milestone at 1, 100, 1,000, every 10,000th and the first block of a new card, the first-hour marks); persisted, in
api/state.ladder. src/chainfacts.rs: GET /api/ladder, getFinalityWeights through the node's EVM port when it answers
igneum_getFinalityWeights (owed), else the observer's relay plus /api/stats; this machine's keys ranked; the source
named. src/card.rs and POST /api/card: the page's 1200x630 PNG written under <data root>/cards/ and revealed.
settings.profile_public behind api/settings. Hooks in engine.rs (block, node line, vote, lock, synced, mining) and
prover.rs (paid shard). Box: 190 + 27 + 8 tests.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
(cherry picked from commit 959c4a5fa9)
This commit is contained in:
parent
12d8632701
commit
8a8be1da92
10 changed files with 959 additions and 4 deletions
80
app/igneum-app/src/card.rs
Normal file
80
app/igneum-app/src/card.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
//! The saved block card (miner-ui-5): the page draws the 1200x630 PNG on a canvas from the same words the card
|
||||
//! shows (no network call), posts it as a data URL to POST /api/card, and the engine writes it under
|
||||
//! `<data root>/cards/`. This module is the pure part: the base64 decode (no crate for it) and the file name.
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// `data:image/png;base64,....` -> the PNG bytes; None for anything that is not a base64 PNG data URL.
|
||||
pub fn decode_data_url(s: &str) -> Option<Vec<u8>> {
|
||||
let rest = s.strip_prefix("data:image/png;base64,")?;
|
||||
let bytes = decode_base64(rest)?;
|
||||
if bytes.len() < 8 || bytes[..8] != [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A] {
|
||||
return None;
|
||||
}
|
||||
Some(bytes)
|
||||
}
|
||||
|
||||
/// Standard base64 (RFC 4648, with or without `=` padding, whitespace ignored). None on a bad character.
|
||||
pub fn decode_base64(s: &str) -> Option<Vec<u8>> {
|
||||
let mut out = Vec::with_capacity(s.len() * 3 / 4);
|
||||
let mut acc: u32 = 0;
|
||||
let mut bits = 0u32;
|
||||
for c in s.bytes() {
|
||||
let v = match c {
|
||||
b'A'..=b'Z' => c - b'A',
|
||||
b'a'..=b'z' => c - b'a' + 26,
|
||||
b'0'..=b'9' => c - b'0' + 52,
|
||||
b'+' | b'-' => 62,
|
||||
b'/' | b'_' => 63,
|
||||
b'=' | b'\n' | b'\r' | b' ' | b'\t' => continue,
|
||||
_ => return None,
|
||||
} as u32;
|
||||
acc = (acc << 6) | v;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push(((acc >> bits) & 0xFF) as u8);
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// `<root>/cards/igneum-block-<name>-<unix>.png`, the name reduced to [a-z0-9-] and at most 40 characters.
|
||||
pub fn card_path(root: &Path, name: &str, unix: u64) -> PathBuf {
|
||||
let mut clean = String::new();
|
||||
for c in name.to_ascii_lowercase().chars() {
|
||||
if c.is_ascii_alphanumeric() { clean.push(c); } else if !clean.ends_with('-') { clean.push('-'); }
|
||||
}
|
||||
let clean: String = clean.trim_matches('-').chars().take(40).collect::<String>().trim_end_matches('-').to_string();
|
||||
let clean = if clean.is_empty() { "block".to_string() } else { clean };
|
||||
root.join("cards").join(format!("igneum-block-{clean}-{unix}.png"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn base64_decodes_with_and_without_padding() {
|
||||
assert_eq!(decode_base64("aGVsbG8=").unwrap(), b"hello");
|
||||
assert_eq!(decode_base64("aGVsbG8").unwrap(), b"hello");
|
||||
assert_eq!(decode_base64("aGVs\nbG8gd29ybGQ=").unwrap(), b"hello world");
|
||||
assert!(decode_base64("aGV$sbG8=").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_png_data_url_decodes_and_anything_else_is_refused() {
|
||||
// the 8-byte PNG signature, base64
|
||||
let sig = "iVBORw0KGgo=";
|
||||
let png = decode_data_url(&format!("data:image/png;base64,{sig}")).unwrap();
|
||||
assert_eq!(&png[..4], b"\x89PNG");
|
||||
assert!(decode_data_url("data:image/jpeg;base64,/9j/").is_none());
|
||||
assert!(decode_data_url("data:image/png;base64,aGVsbG8=").is_none(), "not a PNG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_file_name_is_safe_and_dated() {
|
||||
let p = card_path(Path::new("/data"), "Block 1,284,117 / RTX 4070", 1_791_362_116);
|
||||
assert_eq!(p, PathBuf::from("/data/cards/igneum-block-block-1-284-117-rtx-4070-1791362116.png"));
|
||||
assert_eq!(card_path(Path::new("/d"), "///", 1).file_name().unwrap(), "igneum-block-block-1.png");
|
||||
}
|
||||
}
|
||||
263
app/igneum-app/src/chainfacts.rs
Normal file
263
app/igneum-app/src/chainfacts.rs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
//! GET /api/ladder: the chain facts behind the miner's ladder (miner-ui-5, 7 October 2026). Every rung the dashboard
|
||||
//! shows is a number from the node's finality RPC, never a count this app keeps: `getFinalityWeights` (the per-key
|
||||
//! table: `keys[].blocks` in the weight window, `keys[].voter` against `params.dust`, `keys[].participation` over
|
||||
//! `params.presenceWindow`, `totalWeight`, `activeWeight`, `voters`, `checkpointIndex`) and `getFinalityCheckpoints`
|
||||
//! (`latestLockedIndex`, `nextIndex`, `finalityActive`). The network rate and the reward come from the public
|
||||
//! `/api/stats` (`hashrate`, `block_reward.miner_ign`, `block_reward.ramp_factor`), the observer's reading of the same
|
||||
//! node RPCs.
|
||||
//!
|
||||
//! Sources, in order: the local node's EVM port answering `igneum_getFinalityWeights` (OWED on the node: the finality
|
||||
//! RPCs are wRPC only today and the engine carries no websocket client), else the observer's `/api/live`, whose
|
||||
//! `finality.weights` is the node's `getFinalityWeights` relayed (field names in snake case, `keys[]` cut to 64, key ids
|
||||
//! as the first 8 hex of the key hash). The reply names its source so the dashboard can say it on hover. Cached 10 s.
|
||||
use serde_json::{json, Value};
|
||||
use std::process::Command;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
static CACHE: Mutex<Option<(Instant, Value)>> = Mutex::new(None);
|
||||
const TTL: Duration = Duration::from_secs(10);
|
||||
/// the observer relays at most this many keys (tools/observer/observer.mjs)
|
||||
pub const OBSERVER_KEYS_CAP: usize = 64;
|
||||
|
||||
fn num(v: &Value) -> f64 {
|
||||
match v {
|
||||
Value::Number(n) => n.as_f64().unwrap_or(0.0),
|
||||
Value::String(s) => s.parse().unwrap_or(0.0),
|
||||
Value::Bool(b) => if *b { 1.0 } else { 0.0 },
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
fn u(v: &Value) -> u64 { num(v).max(0.0) as u64 }
|
||||
fn get<'a>(v: &'a Value, keys: &[&str]) -> &'a Value {
|
||||
for k in keys {
|
||||
if let Some(x) = v.get(k) { return x; }
|
||||
}
|
||||
&Value::Null
|
||||
}
|
||||
|
||||
/// One key's row in either spelling (the node's camelCase or the observer's snake case).
|
||||
fn key_row(k: &Value) -> Value {
|
||||
let id_full = get(k, &["keyHash", "key_hash", "id"]).as_str().unwrap_or("").trim_start_matches("0x").to_ascii_lowercase();
|
||||
let id: String = id_full.chars().take(8).collect();
|
||||
let revealed = match k.get("revealed") { Some(r) => r.as_bool().unwrap_or(false), None => k.get("pubkey").and_then(|p| p.as_str()).map(|p| !p.is_empty()).unwrap_or(false) };
|
||||
json!({
|
||||
"id": id,
|
||||
"blocks": u(get(k, &["blocks"])),
|
||||
"voter": get(k, &["voter"]).as_bool().unwrap_or(false),
|
||||
"participation": num(get(k, &["participation"])),
|
||||
"stripped_until_daa": u(get(k, &["strippedUntilDaa", "stripped_until_daa"])),
|
||||
"revealed": revealed,
|
||||
})
|
||||
}
|
||||
|
||||
/// Is `id` one of this machine's key ids? A prefix of the other of at least 6 hex counts (the engine keeps 8, a
|
||||
/// source may keep more or fewer).
|
||||
pub fn is_mine(id: &str, ids: &[String]) -> bool {
|
||||
let id = id.trim_start_matches("0x").to_ascii_lowercase();
|
||||
ids.iter().any(|m| {
|
||||
let m = m.trim_start_matches("0x").to_ascii_lowercase();
|
||||
let n = id.len().min(m.len());
|
||||
n >= 6 && id[..n] == m[..n]
|
||||
})
|
||||
}
|
||||
|
||||
/// The reply from a weights table (`w`: the node's `getFinalityWeights` reply, or the observer's
|
||||
/// `finality.weights` with `finality` beside it), the observer's `finality` block (params and the locked index), the
|
||||
/// public stats, and this machine's key ids. Pure.
|
||||
pub fn shape(w: &Value, finality: &Value, stats: &Value, ids: &[String], source: &str, read_at: f64) -> Value {
|
||||
let params = if w.get("params").is_some() { get(w, &["params"]) } else { get(finality, &["params"]) };
|
||||
let mut keys: Vec<Value> = get(w, &["keys"]).as_array().map(|a| a.iter().map(key_row).collect()).unwrap_or_default();
|
||||
keys.sort_by(|a, b| u(&b["blocks"]).cmp(&u(&a["blocks"])).then_with(|| a["id"].as_str().cmp(&b["id"].as_str())));
|
||||
let mut mine: Vec<Value> = Vec::new();
|
||||
for (i, k) in keys.iter().enumerate() {
|
||||
if is_mine(k["id"].as_str().unwrap_or(""), ids) {
|
||||
let mut m = k.clone();
|
||||
m["rank"] = json!(i + 1);
|
||||
mine.push(m);
|
||||
}
|
||||
}
|
||||
let best = mine.iter().min_by_key(|m| u(&m["rank"])).cloned();
|
||||
let reward = get(stats, &["block_reward"]);
|
||||
let latest_locked = if finality.get("latest_locked_index").is_some() { u(get(finality, &["latest_locked_index"])) } else { u(get(get(stats, &["finality"]), &["latest_locked_index"])) };
|
||||
let active = match finality.get("active").or_else(|| finality.get("finality_active")) { Some(a) => a.as_bool().unwrap_or(false), None => get(get(stats, &["finality"]), &["active"]).as_bool().unwrap_or(false) };
|
||||
json!({
|
||||
"ok": true,
|
||||
"source": source,
|
||||
"read_at": read_at,
|
||||
"rpc": {
|
||||
"weights": "getFinalityWeights",
|
||||
"checkpoints": "getFinalityCheckpoints",
|
||||
"stats": "/api/stats",
|
||||
"relay": if source == "node" { "" } else { "igneum.network/api/live (the observer's copy of the node's reply)" }
|
||||
},
|
||||
"params": {
|
||||
"dust": u(get(params, &["dust"])),
|
||||
"weight_window": u(get(params, &["weightWindow", "weight_window"])),
|
||||
"presence_window": u(get(params, &["presenceWindow", "presence_window"])),
|
||||
"checkpoint_interval": u(get(params, &["checkpointInterval", "checkpoint_interval"])),
|
||||
"min_daa": u(get(params, &["minDaa", "min_daa"])),
|
||||
},
|
||||
"checkpoint_index": u(get(w, &["checkpointIndex", "checkpoint_index"])),
|
||||
"daa_score": u(get(w, &["daaScore", "daa_score"])),
|
||||
"latest_locked_index": latest_locked,
|
||||
"next_index": u(get(finality, &["nextIndex", "next_index"])),
|
||||
"finality_active": active,
|
||||
"total_weight": u(get(w, &["totalWeight", "total_weight"])),
|
||||
"active_weight": num(get(w, &["activeWeight", "active_weight"])),
|
||||
"voters": u(get(w, &["voters"])),
|
||||
"keys_listed": keys.len(),
|
||||
"keys_cap": if source == "node" { 0 } else { OBSERVER_KEYS_CAP },
|
||||
"network": {
|
||||
"hashrate_hps": num(get(stats, &["hashrate"])),
|
||||
"miner_ign_per_block": num(get(reward, &["miner_ign"])),
|
||||
"ign_per_block": num(get(reward, &["ign"])),
|
||||
"ramp_factor": num(get(reward, &["ramp_factor"])),
|
||||
"daa": u(get(stats, &["daa"])),
|
||||
"blocks_per_day_measured": u(get(stats, &["blocks_per_day_measured"])),
|
||||
"bps": 1,
|
||||
"stale": get(stats, &["stale"]).as_bool().unwrap_or(stats.is_null()),
|
||||
},
|
||||
"mine": mine,
|
||||
"best": best,
|
||||
})
|
||||
}
|
||||
|
||||
fn curl_json(url: &str) -> Option<Value> {
|
||||
let out = crate::detect::run_timeout(Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "6", url]), None, Duration::from_secs(8))?;
|
||||
serde_json::from_str(out.trim()).ok()
|
||||
}
|
||||
|
||||
/// `https://igneum.network/api/live` -> `https://igneum.network/api/stats`
|
||||
pub fn stats_api_from(live_api: &str) -> String {
|
||||
match live_api.strip_suffix("/api/live") {
|
||||
Some(base) => format!("{base}/api/stats"),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The reply for the dashboard, cached 10 s: the node first, the observer second, `{ok:false}` with the reason when
|
||||
/// neither answers.
|
||||
pub fn fetch(evm_port: u16, live_api: &str, ids: &[String]) -> Value {
|
||||
if let Some((at, v)) = CACHE.lock().unwrap().as_ref() {
|
||||
if at.elapsed() < TTL {
|
||||
let mut c = v.clone();
|
||||
c["cached_age_s"] = json!(at.elapsed().as_secs_f64().round());
|
||||
return c;
|
||||
}
|
||||
}
|
||||
let now = crate::platform::unix_now_f();
|
||||
let stats = if live_api.is_empty() { None } else { curl_json(&stats_api_from(live_api)) };
|
||||
let stats = stats.unwrap_or(Value::Null);
|
||||
let reply = match crate::extnode::rpc(evm_port, "igneum_getFinalityWeights", json!([]), Duration::from_secs(4)) {
|
||||
Some(w) if w.get("keys").is_some() => shape(&w, &Value::Null, &stats, ids, "node", now),
|
||||
_ => match if live_api.is_empty() { None } else { curl_json(&crate::live::url_for(live_api, 60)) } {
|
||||
Some(live) => {
|
||||
let fin = get(&live, &["finality"]);
|
||||
let w = get(fin, &["weights"]);
|
||||
if w.get("keys").is_some() { shape(w, fin, &stats, ids, "observer", now) } else { json!({ "ok": false, "error": "the observer carries no finality weights yet", "source": "observer" }) }
|
||||
}
|
||||
None => json!({ "ok": false, "error": if live_api.is_empty() { "no observer address in this build and the node has no igneum_getFinalityWeights" } else { "neither the node nor the observer answered" }, "source": "" }),
|
||||
},
|
||||
};
|
||||
*CACHE.lock().unwrap() = Some((Instant::now(), reply.clone()));
|
||||
reply
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn observer_live() -> Value {
|
||||
json!({
|
||||
"ok": true,
|
||||
"state": { "hashes_per_second_estimate": 764944722 },
|
||||
"finality": {
|
||||
"supported": true, "active": true, "next_index": 8496, "latest_locked_index": 8495,
|
||||
"params": { "dust": 5, "minDaa": 7200, "aggregators": 8, "weightWindow": 7200, "presenceWindow": 20, "checkpointDepth": 20, "equivocationBan": 7200, "checkpointInterval": 30 },
|
||||
"weights": {
|
||||
"checkpoint_index": 8495, "total_weight": 6000, "active_weight": 5900.5, "voters": 14,
|
||||
"keys": [
|
||||
{ "id": "6e80f3ef", "voter": true, "blocks": 680, "revealed": true, "participation": 1, "stripped_until_daa": 0 },
|
||||
{ "id": "9e4ba6b0", "voter": true, "blocks": 559, "revealed": true, "participation": 1, "stripped_until_daa": 0 },
|
||||
{ "id": "8fafda27", "voter": true, "blocks": 61, "revealed": true, "participation": 0.95, "stripped_until_daa": 0 },
|
||||
{ "id": "00000001", "voter": false, "blocks": 3, "revealed": false, "participation": 0, "stripped_until_daa": 0 }
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
fn stats() -> Value {
|
||||
json!({ "ok": true, "stale": false, "daa": 266454, "hashrate": 764944722, "blocks_per_day_measured": 92280, "block_reward": { "miner_ign": "4.88044084", "ign": "6.10055105", "ramp_factor": 0.192519 }, "finality": { "active": true, "latest_locked_index": 8495 } })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_observer_relay_shapes_into_the_node_fields_with_our_rank() {
|
||||
let live = observer_live();
|
||||
let fin = &live["finality"];
|
||||
let r = shape(&fin["weights"], fin, &stats(), &["8fafda27".into()], "observer", 1.0);
|
||||
assert_eq!(r["ok"], true);
|
||||
assert_eq!(r["source"], "observer");
|
||||
assert_eq!(r["params"]["dust"], 5);
|
||||
assert_eq!(r["params"]["weight_window"], 7200);
|
||||
assert_eq!(r["params"]["presence_window"], 20);
|
||||
assert_eq!(r["latest_locked_index"], 8495);
|
||||
assert_eq!(r["finality_active"], true);
|
||||
assert_eq!(r["voters"], 14);
|
||||
assert_eq!(r["keys_listed"], 4);
|
||||
assert_eq!(r["keys_cap"], 64);
|
||||
assert_eq!(r["network"]["hashrate_hps"], 764944722.0);
|
||||
assert_eq!(r["network"]["miner_ign_per_block"], 4.88044084);
|
||||
assert_eq!(r["mine"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(r["mine"][0]["rank"], 3);
|
||||
assert_eq!(r["mine"][0]["blocks"], 61);
|
||||
assert_eq!(r["mine"][0]["voter"], true);
|
||||
assert_eq!(r["best"]["id"], "8fafda27");
|
||||
assert!(r["rpc"]["relay"].as_str().unwrap().contains("observer"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_node_reply_in_camel_case_shapes_the_same_and_names_no_relay() {
|
||||
let w = json!({
|
||||
"params": { "checkpointInterval": 30, "checkpointDepth": 20, "weightWindow": 2592000, "dust": 100, "presenceWindow": 240, "aggregators": 8, "equivocationBan": 2592000, "minDaa": 7200 },
|
||||
"checkpointIndex": 184220, "checkpointHash": "ab", "daaScore": 1284117, "totalWeight": 2000000, "activeWeight": 1900000.0, "voters": 1204,
|
||||
"keys": [
|
||||
{ "keyHash": "8fafda27aa11bb22cc33dd44", "pubkey": "a1", "blocks": 2592, "voter": true, "participation": 1.0, "strippedUntilDaa": 0 },
|
||||
{ "keyHash": "0000000100000000", "pubkey": "", "blocks": 44, "voter": false, "participation": 0.0, "strippedUntilDaa": 0 }
|
||||
]
|
||||
});
|
||||
let r = shape(&w, &Value::Null, &stats(), &["8fafda27".into()], "node", 2.0);
|
||||
assert_eq!(r["source"], "node");
|
||||
assert_eq!(r["keys_cap"], 0);
|
||||
assert_eq!(r["params"]["dust"], 100);
|
||||
assert_eq!(r["params"]["weight_window"], 2592000);
|
||||
assert_eq!(r["checkpoint_index"], 184220);
|
||||
assert_eq!(r["mine"][0]["id"], "8fafda27");
|
||||
assert_eq!(r["mine"][0]["rank"], 1);
|
||||
assert_eq!(r["mine"][0]["revealed"], true);
|
||||
assert_eq!(r["rpc"]["relay"], "");
|
||||
// the locked index falls back to the public stats when the node reply carries no checkpoints block
|
||||
assert_eq!(r["latest_locked_index"], 8495);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_machine_with_no_key_in_the_table_has_no_rank() {
|
||||
let live = observer_live();
|
||||
let fin = &live["finality"];
|
||||
let r = shape(&fin["weights"], fin, &Value::Null, &["deadbeef".into()], "observer", 1.0);
|
||||
assert_eq!(r["mine"].as_array().unwrap().len(), 0);
|
||||
assert!(r["best"].is_null());
|
||||
assert_eq!(r["network"]["stale"], true);
|
||||
assert_eq!(r["network"]["hashrate_hps"], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn id_matching_takes_a_prefix_of_six_or_more() {
|
||||
assert!(is_mine("8fafda27", &["8fafda27aa11".into()]));
|
||||
assert!(is_mine("0x8fafda27aa", &["8fafda27".into()]));
|
||||
assert!(!is_mine("8fafd", &["8fafda27".into()]));
|
||||
assert!(!is_mine("8fafda28", &["8fafda27".into()]));
|
||||
assert_eq!(stats_api_from("https://igneum.network/api/live"), "https://igneum.network/api/stats");
|
||||
assert_eq!(stats_api_from(""), "");
|
||||
}
|
||||
}
|
||||
|
|
@ -132,6 +132,10 @@ pub struct Settings {
|
|||
/// on when the machine can prove, never switching an explicit on back off). Older installs apply it at their next start.
|
||||
#[serde(default)]
|
||||
pub prove_default_applied: bool,
|
||||
/// miner-ui-5 (7 October 2026): the public address profile on the site is behind this opt-in; off by default, and
|
||||
/// the switch's words say what becomes public (the key ids, the blocks, the weight rank, the card model).
|
||||
#[serde(default)]
|
||||
pub profile_public: bool,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
|
|
@ -146,7 +150,7 @@ fn yes() -> bool {
|
|||
|
||||
impl Default for Settings {
|
||||
fn default() -> Settings {
|
||||
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false }
|
||||
Settings { setup_done: false, address: String::new(), address_source: String::new(), key_saved: false, identities: 1, cards: HashMap::new(), display_name: String::new(), vote: true, paused: false, accepted_total: 0, auto_update: true, remote_jobs: true, prove: false, sweep: true, power_control: false, tune_goal: "balanced".into(), power_price_pence: 0.0, tune_climb: false, installed_at: 0, dev_fee: true, fee_total: 0, proof_verify_trust: false, prove_default_applied: false, profile_public: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ use std::sync::{Arc, Mutex};
|
|||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
/// miner-ui-5: a signing streak breaks when no VOTE line comes for this long (the devnet's presence window, 20
|
||||
/// checkpoints of 30 s; the chain-side fact is `getFinalityWeights.keys[].participation`, this is the local reading)
|
||||
pub const LADDER_GAP_S: f64 = 600.0;
|
||||
const POW_EPOCH_BLOCKS: u64 = 3_600;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
|
|
@ -114,12 +117,17 @@ pub struct Shared {
|
|||
port: std::sync::atomic::AtomicU16,
|
||||
/// set once `state_json` has logged a serialisation error (one line, not one per poll)
|
||||
state_error_logged: std::sync::atomic::AtomicBool,
|
||||
/// miner-ui-5: the machine's own ladder record (src/ladder.rs), `<app dir>/ladder.json`
|
||||
pub ladder: Mutex<crate::ladder::Ladder>,
|
||||
pub ladder_path: PathBuf,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
pub fn new(token: String, runtime: Runtime, packaged: Packaged, settings: Settings, cmd_tx: Sender<Cmd>, engine_log: Option<std::fs::File>, log_path: PathBuf) -> Shared {
|
||||
let settings_path = runtime.app_dir.join("settings.json");
|
||||
let wallet_path = runtime.app_dir.join("wallet.json");
|
||||
let ladder_path = runtime.app_dir.join("ladder.json");
|
||||
let ladder = crate::ladder::Ladder::load(&ladder_path);
|
||||
let mut st = State { version: VERSION.into(), ..Default::default() };
|
||||
st.network = runtime.network.clone();
|
||||
st.chain = if runtime.network == "devnet" { "devnet v4".into() } else { format!("{} (private)", runtime.network) };
|
||||
|
|
@ -137,7 +145,7 @@ impl Shared {
|
|||
st.mining.accepted_total = settings.accepted_total;
|
||||
st.mining.fee_total = settings.fee_total;
|
||||
st.address = address_state(&settings, &wallet_path);
|
||||
st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust };
|
||||
st.settings = crate::state::SettingsState { identities: settings.identities, vote: settings.vote, start_at_login: crate::platform::start_at_login_is_on(), auto_update: settings.auto_update, remote_jobs: settings.remote_jobs, prove: settings.prove, sweep: settings.sweep, power_control: settings.power_control, power_note: String::new(), tuning_off: false, tuning_note: String::new(), tune_goal: settings.tune_goal.clone(), power_price_pence: settings.power_price_pence, tune_climb: settings.tune_climb, tune_period_s: crate::ember::PERIOD_S, dev_fee: settings.dev_fee, proof_verify_trust: settings.proof_verify_trust, profile_public: settings.profile_public };
|
||||
st.dev_fee = crate::state::DevFeeState { on: settings.dev_fee, percent: if settings.dev_fee { 1 } else { 0 }, address: String::new(), line: String::new() };
|
||||
st.live_page = packaged.live_page.clone();
|
||||
st.finality.message = "waiting for the miner".into();
|
||||
|
|
@ -159,9 +167,21 @@ impl Shared {
|
|||
log_path,
|
||||
port: std::sync::atomic::AtomicU16::new(0),
|
||||
state_error_logged: std::sync::atomic::AtomicBool::new(false),
|
||||
ladder: Mutex::new(ladder),
|
||||
ladder_path,
|
||||
}
|
||||
}
|
||||
|
||||
/// miner-ui-5: the ladder record to disk (a few KB; after a block, a lock or a paid shard)
|
||||
pub fn save_ladder(&self) {
|
||||
self.ladder.lock().unwrap().save(&self.ladder_path);
|
||||
}
|
||||
/// miner-ui-5: a paid shard record of ours (src/prover.rs) joins the ladder's shard list
|
||||
pub fn ladder_shard_paid(&self, block: u64, shard: u64, wei: u128) {
|
||||
self.ladder.lock().unwrap().on_shard_paid(crate::platform::unix_now_f(), block, shard, wei);
|
||||
self.save_ladder();
|
||||
}
|
||||
|
||||
/// The first line of every upload, so the console's Machines tab parses it without guessing:
|
||||
/// IGNEUM-APP version=<v> machine=<id8> platform=<os> node=<igneumd version>. Logged at every engine start too,
|
||||
/// so the restart after an OTA apply carries it again.
|
||||
|
|
@ -201,6 +221,7 @@ impl Shared {
|
|||
st.now = crate::platform::unix_now_f();
|
||||
st.uptime_s = self.started.elapsed().as_secs();
|
||||
st.events = self.rings.lock().unwrap().events.iter().cloned().collect();
|
||||
st.ladder = self.ladder.lock().unwrap().state(st.now, LADDER_GAP_S);
|
||||
match serde_json::to_value(st) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
|
|
@ -337,7 +358,7 @@ impl Shared {
|
|||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
pub fn apply_settings(&self, identities: Option<u32>, vote: Option<bool>, login: Option<bool>, address: Option<&str>, display_name: Option<&str>, dev_fee: Option<bool>, proof_verify_trust: Option<bool>) -> Result<Value, String> {
|
||||
pub fn apply_settings(&self, identities: Option<u32>, vote: Option<bool>, login: Option<bool>, address: Option<&str>, display_name: Option<&str>, dev_fee: Option<bool>, proof_verify_trust: Option<bool>, profile_public: Option<bool>) -> Result<Value, String> {
|
||||
let mut restart = Vec::new();
|
||||
let mut restart_node: Option<String> = None;
|
||||
{
|
||||
|
|
@ -371,6 +392,12 @@ impl Shared {
|
|||
restart_node = Some(if v { "proof trust mode on (devnet only)".into() } else { "proof trust mode off".into() });
|
||||
}
|
||||
}
|
||||
if let Some(v) = profile_public {
|
||||
if v != s.profile_public {
|
||||
s.profile_public = v;
|
||||
self.event("info", if v { "public profile on: your key ids, blocks, weight rank and card model may be shown on igneum.network" } else { "public profile off: the address page stays unlisted" });
|
||||
}
|
||||
}
|
||||
if let Some(a) = address {
|
||||
let a = a.trim().to_ascii_lowercase();
|
||||
if !a.is_empty() && a != s.address {
|
||||
|
|
@ -390,6 +417,7 @@ impl Shared {
|
|||
st.settings.vote = s.vote;
|
||||
st.settings.dev_fee = s.dev_fee;
|
||||
st.settings.proof_verify_trust = s.proof_verify_trust;
|
||||
st.settings.profile_public = s.profile_public;
|
||||
st.dev_fee.on = s.dev_fee;
|
||||
st.dev_fee.percent = if s.dev_fee { 1 } else { 0 };
|
||||
st.address = address_state(&s, &self.wallet_path);
|
||||
|
|
@ -3930,6 +3958,11 @@ impl Engine {
|
|||
/// igneumd's own lines. One matters for the user: relayed blocks refused as "too far into the future", which is
|
||||
/// this machine's clock running behind the network (PC 2, 4 October 2026: 60 s slow after a power cut, 0 blocks).
|
||||
fn node_line(&mut self, text: &str) {
|
||||
// miner-ui-5: every block the node validates, ours and relayed, by nonce; the miner's ACCEPTED line claims ours
|
||||
if let Some((hash, daa, nonce)) = crate::ladder::parse_node_accepted(text) {
|
||||
self.shared.ladder.lock().unwrap().on_node_accepted(&hash, daa, &nonce);
|
||||
return;
|
||||
}
|
||||
if let Some(d) = digest_from_line(text) {
|
||||
let mut st = self.st();
|
||||
if st.node.consensus_digest != d {
|
||||
|
|
@ -4119,6 +4152,8 @@ impl Engine {
|
|||
}
|
||||
if synced && !was_synced {
|
||||
self.shared.event("ok", &format!("node synced: {blocks} blocks, {peers} peer(s)"));
|
||||
// miner-ui-5: the first-hour timeline's "node synced" mark, once per install
|
||||
if self.shared.ladder.lock().unwrap().mark("synced", crate::platform::unix_now_f()) { self.shared.save_ladder(); }
|
||||
let t = self.secs(now);
|
||||
let names: Vec<String> = self.st().mining.cards.iter().map(|c| c.name.clone()).collect();
|
||||
for m in self.miners.iter_mut() {
|
||||
|
|
@ -4244,8 +4279,16 @@ impl Engine {
|
|||
if let Some(c) = st.mining.cards.get_mut(card) {
|
||||
c.accepted += 1;
|
||||
}
|
||||
let card_key = st.mining.cards.get(card).map(|c| c.key.clone()).unwrap_or_default();
|
||||
drop(st);
|
||||
self.shared.event("block", &format!("block accepted by the node ({card_name})"));
|
||||
// miner-ui-5: the machine's own ladder record (the block card, the days ring); the hash joins by nonce
|
||||
let nonce = crate::ladder::parse_miner_accepted(text).unwrap_or_default();
|
||||
let milestone = self.shared.ladder.lock().unwrap().on_block(unix, &card_key, &card_name, &nonce, total);
|
||||
self.shared.save_ladder();
|
||||
if let Some(m) = milestone {
|
||||
self.shared.event("block", &crate::ladder::milestone_event(&m));
|
||||
}
|
||||
} else if let Some(s) = crate::watchdog::parse_status(text) {
|
||||
let now = Instant::now();
|
||||
self.miners[i].last_status = Some(now);
|
||||
|
|
@ -4284,6 +4327,8 @@ impl Engine {
|
|||
c.message = if s.mismatched > 0 { format!("{} share(s) failed the CPU re-check this run", s.mismatched) } else { String::new() };
|
||||
}
|
||||
}
|
||||
// miner-ui-5: the first-hour timeline's "card mining" mark, once per install, on the first status with a rate
|
||||
if s.hash_now > 0.0 && self.shared.ladder.lock().unwrap().mark("mining", crate::platform::unix_now_f()) { self.shared.save_ladder(); }
|
||||
} else if text.contains(" worker: race ") {
|
||||
self.race_line(card, &card_name, text);
|
||||
} else if text.contains(" worker: ready ") {
|
||||
|
|
@ -4345,6 +4390,10 @@ impl Engine {
|
|||
st.finality.last_lock_at = crate::platform::unix_now_f();
|
||||
st.finality.age_s = 0.0;
|
||||
}
|
||||
drop(st);
|
||||
// miner-ui-5: every checkpoint this machine signed at or under the lock is in a certificate now
|
||||
self.shared.ladder.lock().unwrap().on_lock(n);
|
||||
self.shared.save_ladder();
|
||||
}
|
||||
} else if text.contains("finality_reason=") {
|
||||
// the node lane's pause line (0.3.16): the cause and who holds it, shown in the one sentence
|
||||
|
|
@ -4360,6 +4409,9 @@ impl Engine {
|
|||
st.finality.held_by.clear();
|
||||
} else if text.contains(" VOTE index=") {
|
||||
self.st().finality.votes += 1;
|
||||
if let Some(idx) = crate::ladder::parse_vote_index(text) {
|
||||
self.shared.ladder.lock().unwrap().on_vote(crate::platform::unix_now_f(), idx, LADDER_GAP_S);
|
||||
}
|
||||
} else if text.contains("worker could not prepare") || text.contains(" prepare-failed ") {
|
||||
self.st().program.prepared = false;
|
||||
self.shared.event("error", &format!("{card_name}: the worker could not compile the next program; it compiles at the boundary"));
|
||||
|
|
|
|||
514
app/igneum-app/src/ladder.rs
Normal file
514
app/igneum-app/src/ladder.rs
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
//! The miner's first month, as this machine records it (miner-ui-5, mission item 6, 7 October 2026). The ladder's
|
||||
//! rungs are chain facts (src/chainfacts.rs reads them from `getFinalityWeights` and `getFinalityCheckpoints`); this
|
||||
//! module keeps what only this machine knows: when its first block came and on which card, the blocks it found per day
|
||||
//! (the days-mined ring), every checkpoint its miners signed and which of those locked (the signing streak), the shards
|
||||
//! its cards were paid for, and the block card to show (the first block, every 100th, 1,000th and 10,000th, the first
|
||||
//! on a new card). Persisted as `<app dir>/ladder.json`; pure, no clock of its own (every call takes `now`).
|
||||
//!
|
||||
//! A block is matched to its hash by the nonce: the miner prints `ACCEPTED block nonce=0x..` for ours, the node prints
|
||||
//! `PoW accepted <hash> by .. (daa N, .., nonce 0x..)` for every block it validates, ours and relayed alike, so the
|
||||
//! nonce is the join and nothing else is.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const DAY_S: f64 = 86_400.0;
|
||||
/// days kept in the ring (the 30-day window plus a month of slack for the view)
|
||||
const KEEP_DAYS: u64 = 60;
|
||||
const KEEP_BLOCKS: usize = 30;
|
||||
const KEEP_SHARDS: usize = 50;
|
||||
const KEEP_PENDING: usize = 400;
|
||||
const KEEP_SIGNED: usize = 400;
|
||||
/// the block counts that raise a card (then every 10,000th)
|
||||
pub const MILESTONES: [u64; 4] = [1, 100, 1_000, 10_000];
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct DayBlocks {
|
||||
/// unix day number (unix seconds / 86,400, UTC)
|
||||
pub day: u64,
|
||||
pub blocks: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct BlockFound {
|
||||
pub hash: String,
|
||||
pub daa: u64,
|
||||
pub nonce: String,
|
||||
pub at: f64,
|
||||
pub card: String,
|
||||
pub card_name: String,
|
||||
/// this machine's lifetime count at that block (1 = the first)
|
||||
pub count: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ShardPaid {
|
||||
pub block: u64,
|
||||
pub shard: u64,
|
||||
/// wei as a decimal string (u128 does not survive every JSON reader)
|
||||
pub wei: String,
|
||||
pub at: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct Milestone {
|
||||
/// first | hundred | thousand | ten_thousand | card
|
||||
pub kind: String,
|
||||
pub count: u64,
|
||||
pub card: String,
|
||||
pub card_name: String,
|
||||
pub hash: String,
|
||||
pub daa: u64,
|
||||
pub at: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
|
||||
pub struct Ladder {
|
||||
#[serde(default)]
|
||||
pub first_block_at: f64,
|
||||
#[serde(default)]
|
||||
pub first_block_card: String,
|
||||
#[serde(default)]
|
||||
pub first_block_hash: String,
|
||||
#[serde(default)]
|
||||
pub first_block_daa: u64,
|
||||
#[serde(default)]
|
||||
pub days: Vec<DayBlocks>,
|
||||
/// card key -> unix s of its first block
|
||||
#[serde(default)]
|
||||
pub cards_first: BTreeMap<String, f64>,
|
||||
#[serde(default)]
|
||||
pub blocks: Vec<BlockFound>,
|
||||
#[serde(default)]
|
||||
pub votes_signed: u64,
|
||||
#[serde(default)]
|
||||
pub last_vote_index: u64,
|
||||
#[serde(default)]
|
||||
pub last_vote_at: f64,
|
||||
/// checkpoints signed and not yet seen locked
|
||||
#[serde(default)]
|
||||
pub signed_open: Vec<u64>,
|
||||
#[serde(default)]
|
||||
pub last_signed_locked: u64,
|
||||
#[serde(default)]
|
||||
pub signed_locked_count: u64,
|
||||
/// when the unbroken run of votes began (0 = no vote yet)
|
||||
#[serde(default)]
|
||||
pub streak_since: f64,
|
||||
#[serde(default)]
|
||||
pub shards: Vec<ShardPaid>,
|
||||
#[serde(default)]
|
||||
pub milestone: Option<Milestone>,
|
||||
/// the first-hour timeline's marks: when this install's node first synced and a card first mined (0 = not yet)
|
||||
#[serde(default)]
|
||||
pub first_synced_at: f64,
|
||||
#[serde(default)]
|
||||
pub first_mining_at: f64,
|
||||
/// node-accepted blocks waiting for the miner's own ACCEPTED line (nonce -> hash, daa), and the reverse case
|
||||
#[serde(default)]
|
||||
pending: Vec<(String, String, u64)>,
|
||||
}
|
||||
|
||||
/// What the dashboard reads (api/state.ladder): the record above summarised at `now`.
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
pub struct LadderState {
|
||||
pub first_block_at: f64,
|
||||
pub first_block_card: String,
|
||||
pub first_block_hash: String,
|
||||
pub first_block_daa: u64,
|
||||
/// distinct UTC days with at least one block in the last 30 days
|
||||
pub days_mined_30: u64,
|
||||
pub blocks_30d: u64,
|
||||
pub blocks_today: u64,
|
||||
pub days: Vec<DayBlocks>,
|
||||
pub cards_first: BTreeMap<String, f64>,
|
||||
pub blocks: Vec<BlockFound>,
|
||||
pub votes_signed: u64,
|
||||
pub last_vote_index: u64,
|
||||
pub last_vote_at: f64,
|
||||
pub last_signed_locked: u64,
|
||||
pub signed_locked_count: u64,
|
||||
/// seconds of unbroken signing (0 = none)
|
||||
pub streak_s: f64,
|
||||
pub shards: Vec<ShardPaid>,
|
||||
pub milestone: Option<Milestone>,
|
||||
pub first_synced_at: f64,
|
||||
pub first_mining_at: f64,
|
||||
}
|
||||
|
||||
pub fn day_of(unix: f64) -> u64 {
|
||||
(unix.max(0.0) / DAY_S).floor() as u64
|
||||
}
|
||||
|
||||
impl Ladder {
|
||||
pub fn load(path: &std::path::Path) -> Ladder {
|
||||
std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or_default()
|
||||
}
|
||||
pub fn save(&self, path: &std::path::Path) {
|
||||
if let Some(d) = path.parent() {
|
||||
let _ = std::fs::create_dir_all(d);
|
||||
}
|
||||
if let Ok(t) = serde_json::to_string_pretty(self) {
|
||||
let _ = std::fs::write(path, t);
|
||||
}
|
||||
}
|
||||
|
||||
/// The node validated a block (its own log line): remembered by nonce until the miner's line claims it.
|
||||
pub fn on_node_accepted(&mut self, hash: &str, daa: u64, nonce: &str) {
|
||||
let nonce = norm_nonce(nonce);
|
||||
if nonce.is_empty() || hash.is_empty() {
|
||||
return;
|
||||
}
|
||||
// a block of ours whose miner line came first: fill the hash in now
|
||||
if let Some(b) = self.blocks.iter_mut().find(|b| b.hash.is_empty() && b.nonce == nonce) {
|
||||
b.hash = hash.to_string();
|
||||
b.daa = daa;
|
||||
if self.first_block_hash.is_empty() && self.first_block_at == b.at {
|
||||
self.first_block_hash = hash.to_string();
|
||||
self.first_block_daa = daa;
|
||||
}
|
||||
if let Some(m) = self.milestone.as_mut() {
|
||||
if m.hash.is_empty() && m.at == b.at {
|
||||
m.hash = hash.to_string();
|
||||
m.daa = daa;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.pending.push((nonce, hash.to_string(), daa));
|
||||
if self.pending.len() > KEEP_PENDING {
|
||||
let n = self.pending.len() - KEEP_PENDING;
|
||||
self.pending.drain(0..n);
|
||||
}
|
||||
}
|
||||
|
||||
/// The miner's own ACCEPTED line: `total` is this machine's lifetime count including this block. Returns the
|
||||
/// milestone this block raised, if any.
|
||||
pub fn on_block(&mut self, now: f64, card: &str, card_name: &str, nonce: &str, total: u64) -> Option<Milestone> {
|
||||
let nonce = norm_nonce(nonce);
|
||||
let (hash, daa) = match self.pending.iter().position(|p| !nonce.is_empty() && p.0 == nonce) {
|
||||
Some(i) => {
|
||||
let p = self.pending.remove(i);
|
||||
(p.1, p.2)
|
||||
}
|
||||
None => (String::new(), 0),
|
||||
};
|
||||
let day = day_of(now);
|
||||
match self.days.iter_mut().find(|d| d.day == day) {
|
||||
Some(d) => d.blocks += 1,
|
||||
None => self.days.push(DayBlocks { day, blocks: 1 }),
|
||||
}
|
||||
self.days.sort_by_key(|d| d.day);
|
||||
if self.days.len() > KEEP_DAYS as usize {
|
||||
let n = self.days.len() - KEEP_DAYS as usize;
|
||||
self.days.drain(0..n);
|
||||
}
|
||||
if self.first_block_at == 0.0 {
|
||||
self.first_block_at = now;
|
||||
self.first_block_card = card.to_string();
|
||||
self.first_block_hash = hash.clone();
|
||||
self.first_block_daa = daa;
|
||||
}
|
||||
let new_card = !card.is_empty() && !self.cards_first.contains_key(card);
|
||||
if new_card {
|
||||
self.cards_first.insert(card.to_string(), now);
|
||||
}
|
||||
self.blocks.insert(0, BlockFound { hash: hash.clone(), daa, nonce, at: now, card: card.to_string(), card_name: card_name.to_string(), count: total });
|
||||
self.blocks.truncate(KEEP_BLOCKS);
|
||||
let kind = if total == 1 { "first" } else if total == 100 { "hundred" } else if total == 1_000 { "thousand" } else if total >= 10_000 && total % 10_000 == 0 { "ten_thousand" } else if new_card { "card" } else { "" };
|
||||
if kind.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let m = Milestone { kind: kind.into(), count: total, card: card.into(), card_name: card_name.into(), hash, daa, at: now };
|
||||
self.milestone = Some(m.clone());
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// A `VOTE index=N` line from a miner: the streak holds while votes keep coming inside `gap_s`.
|
||||
pub fn on_vote(&mut self, now: f64, index: u64, gap_s: f64) {
|
||||
self.votes_signed += 1;
|
||||
if self.streak_since == 0.0 || (self.last_vote_at > 0.0 && now - self.last_vote_at > gap_s) {
|
||||
self.streak_since = now;
|
||||
}
|
||||
self.last_vote_at = now;
|
||||
if index > self.last_vote_index {
|
||||
self.last_vote_index = index;
|
||||
}
|
||||
if index > 0 && !self.signed_open.contains(&index) {
|
||||
self.signed_open.push(index);
|
||||
if self.signed_open.len() > KEEP_SIGNED {
|
||||
let n = self.signed_open.len() - KEEP_SIGNED;
|
||||
self.signed_open.drain(0..n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A `LOCK checkpoint N` line: every signed index at or under N is in a locked certificate now.
|
||||
pub fn on_lock(&mut self, index: u64) {
|
||||
let (locked, open): (Vec<u64>, Vec<u64>) = self.signed_open.iter().partition(|&&i| i <= index);
|
||||
if let Some(&top) = locked.iter().max() {
|
||||
if top > self.last_signed_locked {
|
||||
self.last_signed_locked = top;
|
||||
}
|
||||
self.signed_locked_count += locked.len() as u64;
|
||||
}
|
||||
self.signed_open = open;
|
||||
}
|
||||
|
||||
/// The timeline's first-time marks ("synced", "mining"): set once, kept. Returns true when it was new.
|
||||
pub fn mark(&mut self, what: &str, now: f64) -> bool {
|
||||
let slot = match what { "synced" => &mut self.first_synced_at, "mining" => &mut self.first_mining_at, _ => return false };
|
||||
if *slot > 0.0 { return false; }
|
||||
*slot = now;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn on_shard_paid(&mut self, now: f64, block: u64, shard: u64, wei: u128) {
|
||||
if self.shards.iter().any(|s| s.block == block && s.shard == shard) {
|
||||
return;
|
||||
}
|
||||
self.shards.insert(0, ShardPaid { block, shard, wei: wei.to_string(), at: now });
|
||||
self.shards.truncate(KEEP_SHARDS);
|
||||
}
|
||||
|
||||
/// Seconds of unbroken signing at `now` (0 when the last vote is older than `gap_s`).
|
||||
pub fn streak_s(&self, now: f64, gap_s: f64) -> f64 {
|
||||
if self.streak_since == 0.0 || self.last_vote_at == 0.0 || now - self.last_vote_at > gap_s {
|
||||
return 0.0;
|
||||
}
|
||||
(now - self.streak_since).max(0.0)
|
||||
}
|
||||
|
||||
fn blocks_since(&self, from: f64) -> u64 {
|
||||
// the day ring is daily; the finer windows read the block list and fall back to the ring
|
||||
self.days.iter().filter(|d| (d.day as f64 + 1.0) * DAY_S > from).map(|d| d.blocks).sum()
|
||||
}
|
||||
|
||||
pub fn state(&self, now: f64, gap_s: f64) -> LadderState {
|
||||
let today = day_of(now);
|
||||
let window_from = now - 30.0 * DAY_S;
|
||||
let days_mined_30 = self.days.iter().filter(|d| d.blocks > 0 && (d.day as f64 + 1.0) * DAY_S > window_from).count() as u64;
|
||||
LadderState {
|
||||
first_block_at: self.first_block_at,
|
||||
first_block_card: self.first_block_card.clone(),
|
||||
first_block_hash: self.first_block_hash.clone(),
|
||||
first_block_daa: self.first_block_daa,
|
||||
days_mined_30,
|
||||
blocks_30d: self.blocks_since(window_from),
|
||||
blocks_today: self.days.iter().find(|d| d.day == today).map(|d| d.blocks).unwrap_or(0),
|
||||
days: self.days.iter().filter(|d| (d.day as f64 + 1.0) * DAY_S > window_from).cloned().collect(),
|
||||
cards_first: self.cards_first.clone(),
|
||||
blocks: self.blocks.iter().take(10).cloned().collect(),
|
||||
votes_signed: self.votes_signed,
|
||||
last_vote_index: self.last_vote_index,
|
||||
last_vote_at: self.last_vote_at,
|
||||
last_signed_locked: self.last_signed_locked,
|
||||
signed_locked_count: self.signed_locked_count,
|
||||
streak_s: self.streak_s(now, gap_s),
|
||||
shards: self.shards.clone(),
|
||||
milestone: self.milestone.clone(),
|
||||
first_synced_at: self.first_synced_at,
|
||||
first_mining_at: self.first_mining_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Activity line a milestone raises (the dashboard's EVENT_MAP reads these shapes).
|
||||
pub fn milestone_event(m: &Milestone) -> String {
|
||||
match m.kind.as_str() {
|
||||
"first" => format!("first block: found by {} (block card)", m.card_name),
|
||||
"hundred" => format!("block 100 of this machine: found by {} (block card)", m.card_name),
|
||||
"thousand" => format!("block 1,000 of this machine: found by {} (block card)", m.card_name),
|
||||
"ten_thousand" => format!("block {} of this machine: found by {} (block card)", with_commas(m.count), m.card_name),
|
||||
_ => format!("first block on {} (block card)", m.card_name),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_commas(n: u64) -> String {
|
||||
let s = n.to_string();
|
||||
let mut out = String::new();
|
||||
for (i, c) in s.chars().enumerate() {
|
||||
if i > 0 && (s.len() - i) % 3 == 0 { out.push(','); }
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn norm_nonce(n: &str) -> String {
|
||||
let t = n.trim().trim_start_matches("0x").trim_start_matches('0').to_ascii_lowercase();
|
||||
if t.is_empty() && !n.trim().is_empty() { "0".into() } else { t }
|
||||
}
|
||||
|
||||
/// `PoW accepted <hash> by <engine> (daa N, ..., nonce 0x..)` -> (hash, daa, nonce)
|
||||
pub fn parse_node_accepted(text: &str) -> Option<(String, u64, String)> {
|
||||
let rest = text.split("PoW accepted ").nth(1)?;
|
||||
let hash = rest.split_whitespace().next()?.to_string();
|
||||
if hash.len() != 64 || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let daa = rest.split("(daa ").nth(1)?.split(|c: char| !c.is_ascii_digit()).next()?.parse::<u64>().ok()?;
|
||||
let nonce = rest.split("nonce ").nth(1)?.trim_end_matches(')').split_whitespace().next()?.to_string();
|
||||
Some((hash, daa, nonce))
|
||||
}
|
||||
|
||||
/// `... ACCEPTED block nonce=0x.. (...)` -> the nonce
|
||||
pub fn parse_miner_accepted(text: &str) -> Option<String> {
|
||||
let rest = text.split("ACCEPTED block nonce=").nth(1)?;
|
||||
Some(rest.split_whitespace().next()?.to_string())
|
||||
}
|
||||
|
||||
/// `... VOTE index=N ...` -> N
|
||||
pub fn parse_vote_index(text: &str) -> Option<u64> {
|
||||
text.split("VOTE index=").nth(1)?.split(|c: char| !c.is_ascii_digit()).next()?.parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
const NODE: &str = "2026-10-04 20:08:38.486+00:00 [INFO ] PoW accepted 28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce by igneum-lottery-v2-bound (daa 38637, epoch seed c8b574fbbbd6ba93b34f8c9a81042b6970b140ffb24219e381d82b653673ee81, day 20730, nonce 0x267dd27200a91c80)";
|
||||
const MINER: &str = "1791140918.500 ACCEPTED block nonce=0x267dd27200a91c80 (gpu worker, cpu re-check ok, identity mac-01234567-1)";
|
||||
|
||||
#[test]
|
||||
fn the_node_and_miner_lines_parse() {
|
||||
let (h, daa, n) = parse_node_accepted(NODE).unwrap();
|
||||
assert_eq!(h, "28abaa8c28ffb4c3eab96b5dc202f8328df7038dc7b2653c9e4b7dd6369ae5ce");
|
||||
assert_eq!(daa, 38637);
|
||||
assert_eq!(n, "0x267dd27200a91c80");
|
||||
assert_eq!(parse_miner_accepted(MINER).unwrap(), "0x267dd27200a91c80");
|
||||
assert_eq!(parse_vote_index("1791140918.500 VOTE index=1240 checkpoint=abcd signed").unwrap(), 1240);
|
||||
assert!(parse_node_accepted("PoW rejected 00 by x").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_block_is_matched_to_its_hash_by_nonce_and_raises_the_first_card() {
|
||||
let mut l = Ladder::default();
|
||||
let (h, daa, n) = parse_node_accepted(NODE).unwrap();
|
||||
l.on_node_accepted(&h, daa, &n);
|
||||
// a relayed block from another miner sits in pending too and never matches
|
||||
l.on_node_accepted("ef21a8454b2f3fcaf6c32a8b9c959df936770edfdffff61c7198989d9431ea4b", 38643, "0x4bc93aba007eabaa");
|
||||
let m = l.on_block(1_791_140_918.5, "apple::Apple M5 Max", "Apple M5 Max", &parse_miner_accepted(MINER).unwrap(), 1).unwrap();
|
||||
assert_eq!(m.kind, "first");
|
||||
assert_eq!(m.hash, h);
|
||||
assert_eq!(m.daa, 38637);
|
||||
assert_eq!(l.first_block_hash, h);
|
||||
assert_eq!(l.first_block_daa, 38637);
|
||||
assert_eq!(l.first_block_card, "apple::Apple M5 Max");
|
||||
assert_eq!(l.blocks.len(), 1);
|
||||
assert_eq!(l.pending.len(), 1, "the relayed block stays unmatched");
|
||||
let s = l.state(1_791_140_920.0, 600.0);
|
||||
assert_eq!(s.days_mined_30, 1);
|
||||
assert_eq!(s.blocks_30d, 1);
|
||||
assert_eq!(s.blocks_today, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_miner_line_before_the_node_line_gets_its_hash_filled_in() {
|
||||
let mut l = Ladder::default();
|
||||
l.on_block(100.0, "c", "Card", "0x267dd27200a91c80", 1);
|
||||
assert_eq!(l.first_block_hash, "");
|
||||
let (h, daa, n) = parse_node_accepted(NODE).unwrap();
|
||||
l.on_node_accepted(&h, daa, &n);
|
||||
assert_eq!(l.blocks[0].hash, h);
|
||||
assert_eq!(l.first_block_hash, h);
|
||||
assert_eq!(l.milestone.as_ref().unwrap().hash, h);
|
||||
assert_eq!(l.milestone.as_ref().unwrap().daa, 38637);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cards_come_at_1_100_1000_10000_and_on_a_new_card() {
|
||||
let mut l = Ladder::default();
|
||||
let mut kinds = vec![];
|
||||
for total in 1..=1_000u64 {
|
||||
if let Some(m) = l.on_block(1000.0 + total as f64, "a", "A", "", total) { kinds.push((total, m.kind)); }
|
||||
}
|
||||
assert_eq!(kinds, vec![(1, "first".to_string()), (100, "hundred".to_string()), (1000, "thousand".to_string())]);
|
||||
assert_eq!(l.on_block(5000.0, "b", "B", "", 1001).unwrap().kind, "card");
|
||||
assert!(l.on_block(5001.0, "b", "B", "", 1002).is_none());
|
||||
assert_eq!(l.on_block(5002.0, "a", "A", "", 10_000).unwrap().kind, "ten_thousand");
|
||||
assert_eq!(l.on_block(5003.0, "a", "A", "", 20_000).unwrap().kind, "ten_thousand");
|
||||
assert_eq!(l.blocks.len(), KEEP_BLOCKS);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn days_mined_counts_distinct_days_inside_the_window_only() {
|
||||
let mut l = Ladder::default();
|
||||
let now = 40.0 * DAY_S + 100.0;
|
||||
// blocks on days 5, 20, 20, 39 and 40 (today); day 5 is outside the 30-day window
|
||||
for (d, total) in [(5.0, 1), (20.0, 2), (20.0, 3), (39.0, 4), (40.0, 5)] {
|
||||
l.on_block(d * DAY_S + 50.0, "a", "A", "", total);
|
||||
}
|
||||
let s = l.state(now, 600.0);
|
||||
assert_eq!(s.days_mined_30, 3);
|
||||
assert_eq!(s.blocks_30d, 4);
|
||||
assert_eq!(s.blocks_today, 1);
|
||||
assert_eq!(s.days.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_signing_streak_holds_across_votes_and_breaks_on_a_gap() {
|
||||
let mut l = Ladder::default();
|
||||
l.on_vote(1000.0, 10, 600.0);
|
||||
l.on_vote(1030.0, 11, 600.0);
|
||||
l.on_vote(1060.0, 12, 600.0);
|
||||
assert_eq!(l.streak_s(1090.0, 600.0), 90.0);
|
||||
assert_eq!(l.votes_signed, 3);
|
||||
assert_eq!(l.last_vote_index, 12);
|
||||
// the lock at 11 puts 10 and 11 in locked certificates; 12 stays open
|
||||
l.on_lock(11);
|
||||
assert_eq!(l.last_signed_locked, 11);
|
||||
assert_eq!(l.signed_locked_count, 2);
|
||||
assert_eq!(l.signed_open, vec![12]);
|
||||
// a gap longer than the presence window ends the streak; the next vote starts a new one
|
||||
assert_eq!(l.streak_s(1700.0, 600.0), 0.0);
|
||||
l.on_vote(1700.0, 33, 600.0);
|
||||
assert_eq!(l.streak_s(1730.0, 600.0), 30.0);
|
||||
assert_eq!(l.state(1730.0, 600.0).streak_s, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_milestone_events_read_in_the_users_words() {
|
||||
let m = |kind: &str, count: u64| Milestone { kind: kind.into(), count, card: "a".into(), card_name: "RTX 4070".into(), hash: String::new(), daa: 0, at: 0.0 };
|
||||
assert_eq!(milestone_event(&m("first", 1)), "first block: found by RTX 4070 (block card)");
|
||||
assert_eq!(milestone_event(&m("hundred", 100)), "block 100 of this machine: found by RTX 4070 (block card)");
|
||||
assert_eq!(milestone_event(&m("ten_thousand", 20_000)), "block 20,000 of this machine: found by RTX 4070 (block card)");
|
||||
assert_eq!(milestone_event(&m("card", 7)), "first block on RTX 4070 (block card)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_timeline_marks_are_set_once() {
|
||||
let mut l = Ladder::default();
|
||||
assert!(l.mark("synced", 10.0));
|
||||
assert!(!l.mark("synced", 20.0));
|
||||
assert!(l.mark("mining", 30.0));
|
||||
assert!(!l.mark("other", 40.0));
|
||||
let s = l.state(50.0, 600.0);
|
||||
assert_eq!(s.first_synced_at, 10.0);
|
||||
assert_eq!(s.first_mining_at, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paid_shards_are_kept_once_each_newest_first() {
|
||||
let mut l = Ladder::default();
|
||||
l.on_shard_paid(10.0, 1_284_117, 3, 1_150_000_000_000_000_000);
|
||||
l.on_shard_paid(11.0, 1_284_117, 3, 1_150_000_000_000_000_000);
|
||||
l.on_shard_paid(12.0, 1_284_120, 0, 2_000_000_000_000_000_000);
|
||||
assert_eq!(l.shards.len(), 2);
|
||||
assert_eq!(l.shards[0].block, 1_284_120);
|
||||
assert_eq!(l.shards[1].wei, "1150000000000000000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_record_survives_a_save_and_load() {
|
||||
let mut l = Ladder::default();
|
||||
l.on_block(100.0, "a", "A", "0x1", 1);
|
||||
l.on_vote(130.0, 5, 600.0);
|
||||
let dir = std::env::temp_dir().join(format!("igneum-ladder-test-{}", std::process::id()));
|
||||
let path = dir.join("ladder.json");
|
||||
l.save(&path);
|
||||
let back = Ladder::load(&path);
|
||||
assert_eq!(back.first_block_at, 100.0);
|
||||
assert_eq!(back.votes_signed, 1);
|
||||
assert_eq!(back.blocks, l.blocks);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
assert_eq!(Ladder::load(&dir.join("missing.json")).votes_signed, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -41,6 +41,9 @@ mod watchdog;
|
|||
mod live;
|
||||
mod extnode;
|
||||
mod merge;
|
||||
mod ladder;
|
||||
mod chainfacts;
|
||||
mod card;
|
||||
|
||||
use std::io::{BufRead, Write};
|
||||
use std::sync::mpsc::channel;
|
||||
|
|
|
|||
|
|
@ -206,6 +206,17 @@ pub fn open_url(url: &str) {
|
|||
let _ = Command::new("xdg-open").arg(url).spawn();
|
||||
}
|
||||
|
||||
/// Shows a file in the system's file browser (the saved block card): Finder with the file selected, Explorer with
|
||||
/// the file selected, the folder on other systems.
|
||||
pub fn reveal_file(path: &std::path::Path) {
|
||||
#[cfg(target_os = "macos")]
|
||||
let _ = Command::new(tool("open")).arg("-R").arg(path).spawn();
|
||||
#[cfg(windows)]
|
||||
let _ = quiet(&mut Command::new(tool("explorer"))).arg(format!("/select,{}", path.display())).spawn();
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
let _ = Command::new("xdg-open").arg(path.parent().unwrap_or(path)).spawn();
|
||||
}
|
||||
|
||||
/// Keeps the machine awake while the engine runs. macOS: caffeinate tied to this process. Windows: the execution state,
|
||||
/// which must be refreshed (call `keep_awake_tick` every minute).
|
||||
pub struct KeepAwake {
|
||||
|
|
|
|||
|
|
@ -515,6 +515,7 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
if paid {
|
||||
submitted.retain(|x| !(x.1 == h && x.2 == s));
|
||||
shared.event("proving", &format!("block {n} shard {s} paid {} IGN", wei as f64 / 1e18));
|
||||
shared.ladder_shard_paid(n, s as u64, wei);
|
||||
set(&shared, |p| {
|
||||
p.paid += 1;
|
||||
p.paid_wei += wei;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ const CSS: &str = include_str!("../ui/app.css");
|
|||
const JS: &str = include_str!("../ui/app.js");
|
||||
const MARK: &str = include_str!("../ui/mark.svg");
|
||||
const DAG_JS: &str = include_str!("../ui/live-dag.js");
|
||||
/// the renderer pack's proof visual (EMBER 02, the site lane's byte-identical copy; miner-ui-5)
|
||||
const PROOF_JS: &str = include_str!("../ui/proof-core.js");
|
||||
const COIN: &[u8] = include_bytes!("../../../brand/igneum-coin-1024.png");
|
||||
const FONT_MONO_400: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-400.woff2");
|
||||
const FONT_MONO_500: &[u8] = include_bytes!("../ui/fonts/IBMPlexMono-500.woff2");
|
||||
|
|
@ -176,6 +178,7 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
|
|||
("GET", "/app.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", JS.as_bytes(), false),
|
||||
("GET", "/mark.svg") => respond(&mut stream, 200, "image/svg+xml", MARK.as_bytes(), true),
|
||||
("GET", "/live-dag.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", DAG_JS.as_bytes(), false),
|
||||
("GET", "/proof-core.js") => respond(&mut stream, 200, "application/javascript; charset=utf-8", PROOF_JS.as_bytes(), false),
|
||||
("GET", "/coin.png") => respond(&mut stream, 200, "image/png", COIN, true),
|
||||
("GET", "/fonts/IBMPlexMono-400.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_400, true),
|
||||
("GET", "/fonts/IBMPlexMono-500.woff2") => respond(&mut stream, 200, "font/woff2", FONT_MONO_500, true),
|
||||
|
|
@ -196,6 +199,13 @@ fn handle(mut stream: TcpStream, shared: Arc<Shared>) {
|
|||
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(&shared, &live_api, window, &ids));
|
||||
}
|
||||
// miner-ui-5: the ladder's chain facts (src/chainfacts.rs): the node's getFinalityWeights through the local
|
||||
// node when it answers igneum_getFinalityWeights, else the observer's relay; cached 10 s
|
||||
("GET", "/api/ladder") => {
|
||||
let live_api = std::env::var("IGNEUM_APP_LIVE_API").unwrap_or_else(|_| crate::ota::live_api_from(&shared.packaged.live_page));
|
||||
let ids: Vec<String> = shared.state.lock().unwrap().mining.cards.iter().flat_map(|c| c.ids.iter().cloned()).collect();
|
||||
json_resp(&mut stream, 200, crate::chainfacts::fetch(shared.runtime.evm_port(), &live_api, &ids));
|
||||
}
|
||||
("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);
|
||||
|
|
@ -293,7 +303,8 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
let display_name = s("display_name");
|
||||
let dev_fee = body.get("dev_fee").and_then(|v| v.as_bool());
|
||||
let proof_verify_trust = body.get("proof_verify_trust").and_then(|v| v.as_bool());
|
||||
shared.apply_settings(identities, vote, login, address.as_deref(), display_name.as_deref(), dev_fee, proof_verify_trust)
|
||||
let profile_public = body.get("profile_public").and_then(|v| v.as_bool());
|
||||
shared.apply_settings(identities, vote, login, address.as_deref(), display_name.as_deref(), dev_fee, proof_verify_trust, profile_public)
|
||||
}
|
||||
"/api/prove" => shared.set_prove(body.get("on").and_then(|v| v.as_bool()).unwrap_or(false)),
|
||||
"/api/prove/setup" => crate::prover::setup(shared),
|
||||
|
|
@ -381,6 +392,18 @@ fn api_post(shared: &Arc<Shared>, path: &str, body: Value) -> Result<Value, Stri
|
|||
// engine asks it over the host protocol ("SHOT <path>", beside URL, STATE and EXIT) and the host writes a PNG of
|
||||
// what it shows to that path under <data root>/shots/, where a collect job's glob "shots/*.png" picks it up.
|
||||
// Read-only: nothing about mining changes. A caller without a host (a bare engine) gets the path and no file.
|
||||
// miner-ui-5: the block card's PNG, drawn by the page on a canvas (no network call), written under
|
||||
// <data root>/cards/ and the folder opened for the user. The body is {name, png: "data:image/png;base64,..."}.
|
||||
"/api/card" => {
|
||||
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("block");
|
||||
let png = body.get("png").and_then(|v| v.as_str()).ok_or("png missing")?;
|
||||
let bytes = crate::card::decode_data_url(png).ok_or("the png is not a base64 data URL")?;
|
||||
let path = crate::card::card_path(&crate::platform::data_root(), name, crate::platform::unix_now());
|
||||
if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); }
|
||||
std::fs::write(&path, &bytes).map_err(|e| format!("could not write the card: {e}"))?;
|
||||
if body.get("reveal").and_then(|v| v.as_bool()).unwrap_or(true) { crate::platform::reveal_file(&path); }
|
||||
Ok(json!({ "ok": true, "path": path.display().to_string(), "bytes": bytes.len() }))
|
||||
}
|
||||
"/api/shot" => {
|
||||
let name = body.get("name").and_then(|v| v.as_str()).unwrap_or("");
|
||||
let path = shot_path(&crate::platform::data_root(), name, crate::platform::unix_now());
|
||||
|
|
|
|||
|
|
@ -361,6 +361,8 @@ pub struct SettingsState {
|
|||
pub dev_fee: bool,
|
||||
/// devnet only: the node trusts proof records without a verifier (`IGNEUM_PROOF_VERIFY=trust`)
|
||||
pub proof_verify_trust: bool,
|
||||
/// miner-ui-5: the public address profile opt-in (settings.profile_public)
|
||||
pub profile_public: bool,
|
||||
}
|
||||
|
||||
/// One remote job this machine ran (the ledger entry), for the Settings history and the last-job strip.
|
||||
|
|
@ -478,6 +480,8 @@ pub struct State {
|
|||
pub now: f64,
|
||||
pub quitting: bool,
|
||||
pub live_page: String,
|
||||
/// miner-ui-5: this machine's own ladder record (src/ladder.rs), summarised at `now`
|
||||
pub ladder: crate::ladder::LadderState,
|
||||
}
|
||||
|
||||
/// Ring buffers behind the state: events (newest first in the JSON) and the log drawer.
|
||||
|
|
|
|||
Loading…
Reference in a new issue