Merge remote-tracking branch 'origin/master' into ca2-v3
# Conflicts: # docs/bench-log.md # proto-opencl/host.c
9
.github/workflows/ci.yml
vendored
|
|
@ -69,15 +69,22 @@ jobs:
|
|||
run: bash tools/ci/no-conflict-markers.sh
|
||||
- name: copied sources are re-stamped before a build
|
||||
run: bash tools/ci/copied-sources-check.sh
|
||||
- name: the signer is never piped into head
|
||||
run: bash tools/ci/signer-pipe-check.sh
|
||||
- name: pinned guest programs match their manifest and are built only by pin-guests.sh
|
||||
run: bash tools/ci/pinned-guests-check.sh
|
||||
- name: no secret file names and no 64-hex secrets in the tree (self-test first, then the tree)
|
||||
run: bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh
|
||||
- name: faucet unit tests (validation, the daily limits, the signed transaction; keccak, RLP and secp256k1 vectors)
|
||||
run: node --test site/api/faucet.test.mjs
|
||||
- name: explorer and public stats unit tests (search router, formatters, emission rule against the node's own test values, the documented API fields from a fixture)
|
||||
run: node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs
|
||||
- name: public stats API answers with the documented fields (the live site; master only, the endpoints exist there after the merge)
|
||||
if: github.ref == 'refs/heads/master'
|
||||
run: node tools/ci/public-api-check.mjs https://igneum.network
|
||||
- name: ship tool self-test (version bump, the dl-both and public manifest helpers)
|
||||
run: node tools/ship-app.mjs --self-test
|
||||
- name: relay unit tests (parsers, secret compare, the wake endpoint)
|
||||
run: node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs
|
||||
- name: miner app notice strip and update card (ordering, keys, wording, timers, when the card shows)
|
||||
run: node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs
|
||||
run: node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs
|
||||
|
|
|
|||
|
|
@ -79,3 +79,6 @@ Every Igneum task in Claude in Chrome runs in the Chrome profile "josh (igneum.n
|
|||
(4 October 2026: three job watchers waited for a line the closing report never starts with, and a failed shard run
|
||||
reported exit 0; the failure was found by hand half an hour later). Every job's outcome and its error lines are read
|
||||
the moment it ends, done or not.
|
||||
|
||||
## Every number carries its consequences (standing rule, 5 October 2026, 21:30 UTC)
|
||||
Josh: "this question and answer should have not needed to be asked ... so that suggestions like this get made without me" (the 12 GB mine-and-prove question, which followed from the 15.6 GB measurement and should have been raised by the agent that measured it). Rule: an agent that measures or reports a number also states, in the same report, what the number means for each user tier and what it will do about it, before anyone asks. The tiers: a home miner with one 8 GB card, one 12 GB card, one 16 GB card, one 24 or 32 GB card; a rig; a pool user; each on Windows, Linux and macOS; each on NVIDIA, AMD and Apple (Intel when it exists). A report that says "peak 15.6 GB" without "so 12 GB cards cannot do both; here is the profile that fits them, measuring now" is incomplete and goes back. The same for hash rates (per watt and per pound for the tiers), times (what deadline it fits), sizes (what disk or memory it needs), and prices. A standing reviewer agent reads every status file, bench-log entry and plan for missed consequences and opens the work; the coordinator does not wait for Josh to notice.
|
||||
|
|
|
|||
2
app/igneum-app/Cargo.lock
generated
|
|
@ -219,7 +219,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "igneum-app"
|
||||
version = "0.3.9"
|
||||
version = "0.3.10"
|
||||
dependencies = [
|
||||
"ed25519-dalek",
|
||||
"getrandom",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "igneum-app"
|
||||
version = "0.3.9"
|
||||
version = "0.3.10"
|
||||
edition = "2021"
|
||||
description = "Igneum Miner engine: supervises the node, the miner and the GPU workers, and serves the dashboard on 127.0.0.1"
|
||||
license = "MIT"
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
1 ICON "igneum.ico"
|
||||
|
||||
1 VERSIONINFO
|
||||
FILEVERSION 0,3,9,0
|
||||
PRODUCTVERSION 0,3,9,0
|
||||
FILEVERSION 0,3,10,0
|
||||
PRODUCTVERSION 0,3,10,0
|
||||
FILEFLAGSMASK 0x3fL
|
||||
FILEFLAGS 0x0L
|
||||
FILEOS VOS_NT_WINDOWS32
|
||||
|
|
@ -20,12 +20,12 @@ BEGIN
|
|||
BEGIN
|
||||
VALUE "CompanyName", "Igneum"
|
||||
VALUE "FileDescription", "Igneum Miner engine"
|
||||
VALUE "FileVersion", "0.3.9"
|
||||
VALUE "FileVersion", "0.3.10"
|
||||
VALUE "InternalName", "igneum-app"
|
||||
VALUE "LegalCopyright", "Igneum contributors"
|
||||
VALUE "OriginalFilename", "igneum-app.exe"
|
||||
VALUE "ProductName", "Igneum Miner"
|
||||
VALUE "ProductVersion", "0.3.9"
|
||||
VALUE "ProductVersion", "0.3.10"
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
//! GPU detection with the real names. macOS: the Metal worker's ready line (the device Metal reports) plus the core
|
||||
//! count from system_profiler. Windows: nvidia-smi for NVIDIA cards, the OpenCL worker's --list for the rest
|
||||
//! (AMD, Intel), and the WMI name list as a last resort when neither tool runs.
|
||||
//! (AMD, Intel), the Windows adapter list (Win32_VideoController: status, problem code, memory) for the cards no
|
||||
//! worker can drive and for the integrated-or-discrete call, and that list's names as a last resort when neither
|
||||
//! tool runs. The engine runs this at start and again every minute (src/hotplug.rs compares the two lists).
|
||||
|
||||
use crate::state::CardState;
|
||||
use std::io::Write;
|
||||
|
|
@ -18,6 +20,38 @@ pub struct Bins {
|
|||
pub dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
/// One enumeration: the cards, the notes for the setup screen, and which tools answered. A tool that did not answer
|
||||
/// (nvidia-smi timed out, the OpenCL worker crashed) says nothing about its cards: the engine keeps them rather
|
||||
/// than calling them removed (src/hotplug.rs).
|
||||
#[derive(Clone, Default)]
|
||||
pub struct Detection {
|
||||
pub cards: Vec<CardState>,
|
||||
pub notes: Vec<String>,
|
||||
/// duplicate OpenCL platform entries left out (one line each, for the log)
|
||||
pub dropped: Vec<String>,
|
||||
pub nvidia_listed: bool,
|
||||
pub opencl_listed: bool,
|
||||
pub adapters_listed: bool,
|
||||
pub metal_listed: bool,
|
||||
}
|
||||
|
||||
impl Detection {
|
||||
/// Whether this enumeration can say that `c` is gone: the tool that lists its vendor answered.
|
||||
pub fn listed(&self, c: &CardState) -> bool {
|
||||
if !c.problem.is_empty() {
|
||||
return self.adapters_listed;
|
||||
}
|
||||
match c.vendor.as_str() {
|
||||
"apple" => self.metal_listed,
|
||||
"nvidia" => self.nvidia_listed,
|
||||
_ => self.opencl_listed || (c.device.is_empty() && self.adapters_listed),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The hint on a card the OS reports as faulty (Windows Code 43, 12, 31 and friends).
|
||||
pub const PROBLEM_HINT: &str = "reboot with the card attached; if it persists, reinstall the driver with the card attached";
|
||||
|
||||
/// Runs a command with a time limit; returns stdout (and stderr appended) or None.
|
||||
pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration) -> Option<String> {
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
|
|
@ -56,7 +90,8 @@ pub fn run_timeout(cmd: &mut Command, stdin_text: Option<&str>, limit: Duration)
|
|||
fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, device: &str) -> CardState {
|
||||
CardState {
|
||||
index,
|
||||
key: format!("{vendor}:{device}:{name}"),
|
||||
key: format!("{vendor}:{name}"),
|
||||
code: name.to_string(),
|
||||
name: name.to_string(),
|
||||
vendor: vendor.into(),
|
||||
worker: worker.into(),
|
||||
|
|
@ -68,14 +103,141 @@ fn card(index: usize, name: &str, vendor: &str, worker: &str, detail: &str, devi
|
|||
}
|
||||
}
|
||||
|
||||
/// Integrated GPUs by name: AMD APUs ("Radeon Graphics", "Vega 8"), Intel iGPUs (Iris, UHD, HD Graphics, Arc A3xx is discrete).
|
||||
#[allow(dead_code)]
|
||||
/// Integrated GPUs by name: AMD APUs ("Radeon Graphics", "Vega 8"), Intel iGPUs (Iris, UHD, HD Graphics, Arc A3xx is
|
||||
/// discrete), and the gfx codes AMD's OpenCL runtime reports instead of a marketing name (the worker's --list prints
|
||||
/// CL_DEVICE_NAME: PC 1's Ryzen iGPU is "gfx1036", 5 October 2026).
|
||||
pub fn looks_integrated(name: &str) -> bool {
|
||||
let n = name.to_ascii_lowercase();
|
||||
let integrated = ["radeon(tm) graphics", "radeon graphics", "vega 8", "vega 7", "vega 6", "vega 3", "vega 11", "iris", "uhd graphics", "hd graphics", "intel(r) graphics", "intel graphics", "apu", "780m", "760m", "680m", "610m", "890m", "880m"];
|
||||
integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ")
|
||||
if integrated.iter().any(|k| n.contains(k)) && !n.contains("arc ") {
|
||||
return true;
|
||||
}
|
||||
// AMD APU graphics by gfx code (approximate list from AMD's ROCm and Mesa target tables): Raven/Picasso gfx902 and
|
||||
// gfx909, Renoir/Cezanne/Lucienne gfx90c, Van Gogh gfx1033, Rembrandt gfx1035, Raphael/Granite Ridge gfx1036,
|
||||
// Mendocino gfx1037, Phoenix gfx1103, Strix gfx1150 to gfx1152. Discrete codes (gfx1030 and so on) are not here.
|
||||
let apu = ["gfx902", "gfx909", "gfx90c", "gfx1033", "gfx1035", "gfx1036", "gfx1037", "gfx1103", "gfx1150", "gfx1151", "gfx1152"];
|
||||
let code = n.trim();
|
||||
apu.iter().any(|k| code == *k || code.starts_with(&format!("{k}:")) || code.starts_with(&format!("{k} ")))
|
||||
}
|
||||
|
||||
/// One row of Windows' adapter list (Win32_VideoController), the part this app reads.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Adapter {
|
||||
pub name: String,
|
||||
/// "OK", "Error", "Degraded", ... (the Status property)
|
||||
pub status: String,
|
||||
/// the PnP problem code (ConfigManagerErrorCode): 0 = fine, 43 = the driver stopped it, 12 = no resources, 31 = not loaded
|
||||
pub code: u32,
|
||||
/// AdapterRAM in MB; 0 = unknown (a faulty card reports 0, and the property caps at 4 GB on 32-bit values)
|
||||
pub ram_mb: u64,
|
||||
pub processor: String,
|
||||
pub pnp_id: String,
|
||||
/// "01:00.0" from DEVPKEY_Device_BusNumber and DEVPKEY_Device_Address; empty when PowerShell could not read them
|
||||
pub bus: String,
|
||||
}
|
||||
|
||||
impl Adapter {
|
||||
/// The PCI device id from the PnP id ("PCI\\VEN_1002&DEV_7550&..." gives 0x7550); 0 when there is none.
|
||||
pub fn device_id(&self) -> u16 {
|
||||
let up = self.pnp_id.to_ascii_uppercase();
|
||||
up.find("DEV_").and_then(|i| u16::from_str_radix(up.get(i + 4..i + 8)?, 16).ok()).unwrap_or(0)
|
||||
}
|
||||
/// "Code 43" for a problem code, "status Error" for a bad status without one, None when the device is fine.
|
||||
pub fn problem(&self) -> Option<String> {
|
||||
if self.code != 0 {
|
||||
return Some(format!("Code {}", self.code));
|
||||
}
|
||||
let st = self.status.trim();
|
||||
if !st.is_empty() && !st.eq_ignore_ascii_case("ok") {
|
||||
return Some(format!("status {st}"));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Integrated or discrete, from the name (APU and iGPU names, AMD gfx codes) and, when Windows' adapter row is
|
||||
/// known, its processor string or a dedicated memory under 1 GB (a shared-memory iGPU; 0 = unknown, says nothing).
|
||||
pub fn classify_kind(name: &str, adapter: Option<&Adapter>) -> &'static str {
|
||||
if looks_integrated(name) {
|
||||
return "integrated";
|
||||
}
|
||||
if let Some(a) = adapter {
|
||||
// the processor string names Intel iGPUs ("Intel(R) Iris(R) Xe Graphics Family"); AMD's reads "AMD Radeon
|
||||
// Graphics Processor (0x7550)" for discrete cards too, so only the Intel markers count here
|
||||
let proc_ = a.processor.to_ascii_lowercase();
|
||||
if looks_integrated(&a.name) || (["iris", "uhd graphics", "hd graphics"].iter().any(|k| proc_.contains(k)) && !proc_.contains("arc")) {
|
||||
return "integrated";
|
||||
}
|
||||
if a.ram_mb > 0 && a.ram_mb < 1024 {
|
||||
return "integrated";
|
||||
}
|
||||
}
|
||||
"discrete"
|
||||
}
|
||||
|
||||
pub fn vendor_of(name: &str) -> &'static str {
|
||||
let n = name.to_ascii_lowercase();
|
||||
if n.contains("nvidia") || n.contains("geforce") {
|
||||
"nvidia"
|
||||
} else if n.contains("amd") || n.contains("radeon") || n.starts_with("gfx") {
|
||||
"amd"
|
||||
} else if n.contains("apple") {
|
||||
"apple"
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `Get-CimInstance Win32_VideoController | Select-Object ... | ConvertTo-Json` (one object or an array).
|
||||
pub fn parse_adapters(json: &str) -> Vec<Adapter> {
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(json.trim()) else { return Vec::new() };
|
||||
let rows: Vec<serde_json::Value> = match v {
|
||||
serde_json::Value::Array(a) => a,
|
||||
o @ serde_json::Value::Object(_) => vec![o],
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let s = |r: &serde_json::Value, k: &str| r.get(k).and_then(|x| x.as_str()).unwrap_or("").trim().to_string();
|
||||
let n = |r: &serde_json::Value, k: &str| r.get(k).and_then(|x| x.as_u64().or_else(|| x.as_str().and_then(|t| t.trim().parse::<u64>().ok()))).unwrap_or(0);
|
||||
rows.iter()
|
||||
.map(|r| {
|
||||
// DEVPKEY_Device_Address on PCI is (device << 16) | function
|
||||
let bus = match (r.get("BusNumber").and_then(|x| x.as_u64()), r.get("Address").and_then(|x| x.as_u64())) {
|
||||
(Some(b), Some(a)) => format!("{:02x}:{:02x}.{:x}", b & 0xff, (a >> 16) & 0xff, a & 0xffff),
|
||||
_ => String::new(),
|
||||
};
|
||||
Adapter { name: s(r, "Name"), status: s(r, "Status"), code: n(r, "ConfigManagerErrorCode") as u32, ram_mb: n(r, "AdapterRAM") / (1024 * 1024), processor: s(r, "VideoProcessor"), pnp_id: s(r, "PNPDeviceID"), bus }
|
||||
})
|
||||
.filter(|a| !a.name.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Windows' adapter list through PowerShell (about a second); None when PowerShell did not answer.
|
||||
#[cfg(windows)]
|
||||
pub fn adapters() -> Option<Vec<Adapter>> {
|
||||
// one object per adapter, with the PCI bus number and address from the PnP properties (they name the card
|
||||
// the OpenCL worker's "pci" field names); @() keeps a single adapter an array
|
||||
let script = "$v = Get-CimInstance Win32_VideoController | ForEach-Object { $id = $_.PNPDeviceID; $bus = $null; $addr = $null; try { foreach ($x in (Get-PnpDeviceProperty -InstanceId $id -KeyName 'DEVPKEY_Device_BusNumber','DEVPKEY_Device_Address' -ErrorAction Stop)) { if ($x.KeyName -eq 'DEVPKEY_Device_BusNumber') { $bus = $x.Data } elseif ($x.KeyName -eq 'DEVPKEY_Device_Address') { $addr = $x.Data } } } catch {}; [pscustomobject]@{ Name = $_.Name; Status = $_.Status; ConfigManagerErrorCode = $_.ConfigManagerErrorCode; AdapterRAM = $_.AdapterRAM; VideoProcessor = $_.VideoProcessor; PNPDeviceID = $id; BusNumber = $bus; Address = $addr } }; ConvertTo-Json -InputObject @($v) -Compress";
|
||||
let out = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", script]), None, Duration::from_secs(15))?;
|
||||
let start = out.find(|c| c == '[' || c == '{')?;
|
||||
Some(parse_adapters(&out[start..]))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[allow(dead_code)]
|
||||
pub fn adapters() -> Option<Vec<Adapter>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Windows' row for a detected card, by name (nvidia-smi and Windows agree on NVIDIA names; AMD's OpenCL runtime
|
||||
/// reports gfx codes, which match nothing here and fall back to the name rules).
|
||||
pub fn adapter_for<'a>(name: &str, adapters: &'a [Adapter]) -> Option<&'a Adapter> {
|
||||
let n = name.trim().to_ascii_lowercase();
|
||||
adapters.iter().find(|a| a.name.trim().to_ascii_lowercase() == n)
|
||||
}
|
||||
|
||||
/// The row's words for an integrated GPU that is off by default (the switch turns it on; the choice is kept).
|
||||
pub const INTEGRATED_REASON: &str = "integrated GPU, off by default (2 to 3 MH/s for 30 W)";
|
||||
|
||||
/// The defaults the launchers use: discrete cards on (8 identities on a big card, 2 on a small one), integrated off
|
||||
/// (1 identity), Apple silicon on with 1.
|
||||
pub fn apply_defaults(c: &mut CardState) {
|
||||
|
|
@ -87,7 +249,7 @@ pub fn apply_defaults(c: &mut CardState) {
|
|||
"integrated" => {
|
||||
c.enabled = false;
|
||||
c.identities = 1;
|
||||
c.reason = "integrated: about 3 MH/s and it shares your system memory. Switch it on if you want it.".into();
|
||||
c.reason = INTEGRATED_REASON.into();
|
||||
}
|
||||
_ => {
|
||||
c.enabled = true;
|
||||
|
|
@ -148,19 +310,20 @@ pub fn nvidia_power_limits() -> std::collections::HashMap<String, (f64, f64, f64
|
|||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
|
||||
let mut cards = Vec::new();
|
||||
pub fn detect(bins: &Bins) -> Detection {
|
||||
let mut d = Detection::default();
|
||||
let Some(metal) = bins.metal.as_ref() else {
|
||||
notes.push("the Metal worker (igneum-bench) is missing from the app".into());
|
||||
return cards;
|
||||
d.notes.push("the Metal worker (igneum-bench) is missing from the app".into());
|
||||
return d;
|
||||
};
|
||||
// the worker's own ready line: "ready metal Apple_M5_Max dataset-log2 28 batch 4194304 prepare 1"
|
||||
let out = run_timeout(Command::new(metal).arg("--serve"), Some("quit\n"), Duration::from_secs(20)).unwrap_or_default();
|
||||
let ready = out.lines().find(|l| l.starts_with("ready "));
|
||||
let Some(ready) = ready else {
|
||||
notes.push(format!("the Metal worker did not report ready: {}", out.lines().last().unwrap_or("no output")));
|
||||
return cards;
|
||||
d.notes.push(format!("the Metal worker did not report ready: {}", out.lines().last().unwrap_or("no output")));
|
||||
return d;
|
||||
};
|
||||
d.metal_listed = true;
|
||||
let fields: Vec<&str> = ready.split_whitespace().collect();
|
||||
let name = fields.get(2).map(|s| s.replace('_', " ")).unwrap_or_else(|| "Apple GPU".into());
|
||||
let prepare = fields.windows(2).any(|w| w[0] == "prepare" && w[1] == "1");
|
||||
|
|
@ -175,58 +338,280 @@ pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
|
|||
}
|
||||
}
|
||||
}
|
||||
if let Some(mem) = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) {
|
||||
if let Ok(b) = mem.trim().parse::<u64>() {
|
||||
let gb = b / (1024 * 1024 * 1024);
|
||||
detail = if detail.is_empty() { format!("{gb} GB unified memory") } else { format!("{detail}, {gb} GB unified memory") };
|
||||
}
|
||||
let mem = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)).and_then(|m| m.trim().parse::<u64>().ok());
|
||||
if let Some(b) = mem {
|
||||
let gb = b / (1024 * 1024 * 1024);
|
||||
detail = if detail.is_empty() { format!("{gb} GB unified memory") } else { format!("{detail}, {gb} GB unified memory") };
|
||||
}
|
||||
if !prepare {
|
||||
notes.push("this Metal worker has no prepare support; the miner restarts at the hour boundary".into());
|
||||
d.notes.push("this Metal worker has no prepare support; the miner restarts at the hour boundary".into());
|
||||
}
|
||||
let mut c = card(0, &name, "apple", "Metal", &detail, "");
|
||||
c.kind = "apple".into();
|
||||
c.path = "prebuilt".into();
|
||||
if let Some(mem) = run_timeout(Command::new(crate::platform::tool("sysctl")).args(["-n", "hw.memsize"]), None, Duration::from_secs(3)) {
|
||||
c.vram_mb = mem.trim().parse::<u64>().map(|b| b / (1024 * 1024)).unwrap_or(0);
|
||||
}
|
||||
c.vram_mb = mem.map(|b| b / (1024 * 1024)).unwrap_or(0);
|
||||
apply_defaults(&mut c);
|
||||
mark_sweep_support(&mut c);
|
||||
cards.push(c);
|
||||
cards
|
||||
d.cards.push(c);
|
||||
assign_keys(&mut d.cards);
|
||||
d
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
|
||||
let mut cards: Vec<CardState> = Vec::new();
|
||||
// NVIDIA: nvidia-smi ships with the driver
|
||||
let smi = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total", "--format=csv,noheader"]), None, Duration::from_secs(10));
|
||||
match smi {
|
||||
/// AMD gfx codes the OpenCL runtime reports as the device name, with the card names Windows uses, the words for
|
||||
/// the row when no adapter matches, and the PCI device ids (approximate, from AMD's public ROCm and Linux driver
|
||||
/// tables; add a line when a card is seen). gfx1036 is the Ryzen desktop iGPU (PC 1: DEV_13C0, 5 October 2026).
|
||||
const GFX: &[(&str, &str, &[&str], &[u16])] = &[
|
||||
("gfx1201", "Radeon RX 9070 XT / 9070", &["Radeon RX 9070 XT", "Radeon RX 9070"], &[0x7550]),
|
||||
("gfx1200", "Radeon RX 9060 XT", &["Radeon RX 9060 XT", "Radeon RX 9060"], &[0x7590]),
|
||||
("gfx1100", "Radeon RX 7900 XTX / XT", &["Radeon RX 7900 XTX", "Radeon RX 7900 XT", "Radeon RX 7900 GRE"], &[0x744C]),
|
||||
("gfx1101", "Radeon RX 7800 XT / 7700 XT", &["Radeon RX 7800 XT", "Radeon RX 7700 XT"], &[0x747E]),
|
||||
("gfx1102", "Radeon RX 7600", &["Radeon RX 7600 XT", "Radeon RX 7600"], &[0x7480]),
|
||||
("gfx1030", "Radeon RX 6800 / 6900", &["Radeon RX 6900 XT", "Radeon RX 6950 XT", "Radeon RX 6800 XT", "Radeon RX 6800"], &[0x73BF]),
|
||||
("gfx1031", "Radeon RX 6700 XT", &["Radeon RX 6750 XT", "Radeon RX 6700 XT", "Radeon RX 6700"], &[0x73DF]),
|
||||
("gfx1032", "Radeon RX 6600", &["Radeon RX 6650 XT", "Radeon RX 6600 XT", "Radeon RX 6600"], &[0x73FF]),
|
||||
("gfx1036", "Ryzen integrated Radeon Graphics", &["Radeon(TM) Graphics", "Radeon Graphics"], &[0x164E, 0x13C0]),
|
||||
("gfx1035", "Radeon 680M (integrated)", &["Radeon 680M", "Radeon 660M"], &[0x1681]),
|
||||
("gfx1103", "Radeon 780M (integrated)", &["Radeon 780M", "Radeon 760M"], &[0x15BF, 0x15C8]),
|
||||
("gfx1150", "Radeon 890M (integrated)", &["Radeon 890M", "Radeon 880M"], &[0x150E]),
|
||||
("gfx90c", "Radeon Graphics (Renoir / Cezanne, integrated)", &["Radeon(TM) Graphics", "Radeon Graphics"], &[0x1636, 0x1638]),
|
||||
];
|
||||
|
||||
fn gfx_entry(code: &str) -> Option<&'static (&'static str, &'static str, &'static [&'static str], &'static [u16])> {
|
||||
let c = code.trim().to_ascii_lowercase();
|
||||
let c = c.split(|ch: char| ch == ':' || ch == ' ').next().unwrap_or("");
|
||||
GFX.iter().find(|e| e.0 == c)
|
||||
}
|
||||
|
||||
/// The name on the row for a device the tool knows by `code`: Windows' adapter name when one matches (by PCI bus,
|
||||
/// else by the gfx code's device ids, else by the card names in the table), else the table's words, else the code.
|
||||
/// `used` holds the adapters already given to another card, so two gfx1036 entries never share one.
|
||||
pub fn resolve_name(code: &str, vendor: &str, bus: &str, adapters: &[Adapter], used: &mut Vec<usize>) -> (String, Option<usize>) {
|
||||
let free = |i: &usize| !used.contains(i);
|
||||
let fine = |a: &Adapter| a.problem().is_none();
|
||||
let vendor_ok = |a: &Adapter| vendor == "other" || vendor_of(&a.name) == vendor;
|
||||
if !bus.is_empty() {
|
||||
if let Some(i) = (0..adapters.len()).filter(free).find(|&i| adapters[i].bus == bus && vendor_ok(&adapters[i])) {
|
||||
used.push(i);
|
||||
return (adapters[i].name.clone(), Some(i));
|
||||
}
|
||||
}
|
||||
// the name is already a marketing name (nvidia-smi, Windows): the adapter with the same name
|
||||
let same: Vec<usize> = (0..adapters.len()).filter(free).filter(|&i| adapters[i].name.trim().eq_ignore_ascii_case(code.trim())).collect();
|
||||
if same.len() == 1 {
|
||||
used.push(same[0]);
|
||||
return (adapters[same[0]].name.clone(), Some(same[0]));
|
||||
}
|
||||
let Some(entry) = gfx_entry(code) else { return (code.to_string(), None) };
|
||||
let by_id: Vec<usize> = (0..adapters.len()).filter(free).filter(|&i| fine(&adapters[i]) && entry.3.contains(&adapters[i].device_id())).collect();
|
||||
if by_id.len() == 1 {
|
||||
used.push(by_id[0]);
|
||||
return (adapters[by_id[0]].name.clone(), Some(by_id[0]));
|
||||
}
|
||||
let by_name: Vec<usize> = (0..adapters.len()).filter(free).filter(|&i| fine(&adapters[i]) && vendor_ok(&adapters[i]) && { let n = adapters[i].name.to_ascii_lowercase(); entry.2.iter().any(|m| n.contains(&m.to_ascii_lowercase())) }).collect();
|
||||
if by_name.len() == 1 {
|
||||
used.push(by_name[0]);
|
||||
return (adapters[by_name[0]].name.clone(), Some(by_name[0]));
|
||||
}
|
||||
(entry.1.to_string(), None)
|
||||
}
|
||||
|
||||
/// One device line pair of the OpenCL worker's --list.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct ClDevice {
|
||||
pub index: String,
|
||||
pub name: String,
|
||||
pub platform: String,
|
||||
pub platform_version: String,
|
||||
pub is_gpu: bool,
|
||||
pub vendor: String,
|
||||
pub driver: String,
|
||||
pub units: String,
|
||||
/// "01:00.0" when the worker printed `pci` (workers from 5 October 2026 on), else empty
|
||||
pub bus: String,
|
||||
pub mem_mb: u64,
|
||||
}
|
||||
|
||||
impl ClDevice {
|
||||
pub fn vendor_word(&self) -> &'static str {
|
||||
if self.vendor.contains("NVIDIA") || self.name.contains("NVIDIA") {
|
||||
"nvidia"
|
||||
} else if self.vendor.contains("Advanced Micro") || self.vendor.contains("AMD") || self.name.contains("Radeon") || self.name.contains("AMD") || self.name.to_ascii_lowercase().starts_with("gfx") {
|
||||
"amd"
|
||||
} else {
|
||||
"other"
|
||||
}
|
||||
}
|
||||
fn platform_key(&self) -> String {
|
||||
format!("{} ({}) driver {}", self.platform, self.platform_version, self.driver)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses `igneum-worker-opencl --list`: `[idx] name | platform (version)` then `GPU, vendor V, driver D, OpenCL C
|
||||
/// x.y, N compute units, M MHz[, pci bb:dd.f]` then `global N MiB, ...`. The bool says the worker printed its header.
|
||||
pub fn parse_opencl_list(text: &str) -> (Vec<ClDevice>, bool) {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let listed = lines.iter().any(|l| l.starts_with("OpenCL devices"));
|
||||
let mut out = Vec::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let t = line.trim_start_matches(|c| c == ' ' || c == '*').trim();
|
||||
if !t.starts_with('[') {
|
||||
continue;
|
||||
}
|
||||
let Some(close) = t.find(']') else { continue };
|
||||
let rest = &t[close + 1..];
|
||||
let mut halves = rest.splitn(2, " |");
|
||||
let name = halves.next().unwrap_or("").trim().to_string();
|
||||
let plat = halves.next().unwrap_or("").trim();
|
||||
// the first " (" opens the version: AMD's version string carries brackets of its own, "OpenCL 2.1 AMD-APP (3617.0)"
|
||||
let (platform, platform_version) = match plat.find(" (") {
|
||||
Some(p) if plat.ends_with(')') => (plat[..p].to_string(), plat[p + 2..plat.len() - 1].to_string()),
|
||||
_ => (plat.to_string(), String::new()),
|
||||
};
|
||||
let info = lines.get(i + 1).map(|l| l.trim()).unwrap_or("");
|
||||
let parts: Vec<&str> = info.split(", ").collect();
|
||||
let mem_mb = lines.get(i + 2).map(|l| l.trim()).and_then(|l| l.strip_prefix("global ")).and_then(|l| l.split_whitespace().next()).and_then(|n| n.parse::<u64>().ok()).unwrap_or(0);
|
||||
out.push(ClDevice {
|
||||
index: t[1..close].to_string(),
|
||||
name,
|
||||
platform,
|
||||
platform_version,
|
||||
is_gpu: info.starts_with("GPU"),
|
||||
vendor: parts.iter().find_map(|p| p.strip_prefix("vendor ")).unwrap_or("").trim().to_string(),
|
||||
driver: parts.iter().find_map(|p| p.strip_prefix("driver ")).unwrap_or("").trim().to_string(),
|
||||
units: parts.iter().find(|p| p.contains("compute units")).unwrap_or(&"").to_string(),
|
||||
bus: parts.iter().find_map(|p| p.strip_prefix("pci ")).unwrap_or("").trim().to_string(),
|
||||
mem_mb,
|
||||
});
|
||||
}
|
||||
(out, listed)
|
||||
}
|
||||
|
||||
fn version_tuple(s: &str) -> Vec<u64> {
|
||||
s.split(|c: char| !c.is_ascii_digit()).filter(|p| !p.is_empty()).map(|p| p.parse::<u64>().unwrap_or(0)).collect()
|
||||
}
|
||||
|
||||
/// One entry per physical card across OpenCL platforms. Two AMD ICDs after a driver upgrade each list every AMD
|
||||
/// card (PC 1, 5 October 2026: gfx1036 and gfx1201 twice, two workers on one 9070 XT). Per vendor, the fuller
|
||||
/// platform wins (most GPUs, then the newest driver, then the first listed); a device on another platform is kept
|
||||
/// only when the winner has no device at the same PCI address (or, without addresses, the same code and ordinal).
|
||||
/// Returns the kept devices and one note per dropped duplicate.
|
||||
pub fn dedupe_platforms(devs: Vec<ClDevice>) -> (Vec<ClDevice>, Vec<String>) {
|
||||
let gpus: Vec<ClDevice> = devs.into_iter().filter(|d| d.is_gpu).collect();
|
||||
let mut kept: Vec<ClDevice> = Vec::new();
|
||||
let mut dropped = Vec::new();
|
||||
let mut vendors: Vec<&'static str> = Vec::new();
|
||||
for d in &gpus {
|
||||
let v = d.vendor_word();
|
||||
if !vendors.contains(&v) {
|
||||
vendors.push(v);
|
||||
}
|
||||
}
|
||||
for v in vendors {
|
||||
let mine: Vec<&ClDevice> = gpus.iter().filter(|d| d.vendor_word() == v).collect();
|
||||
let mut plats: Vec<String> = Vec::new();
|
||||
for d in &mine {
|
||||
let k = d.platform_key();
|
||||
if !plats.contains(&k) {
|
||||
plats.push(k);
|
||||
}
|
||||
}
|
||||
let score = |k: &String| {
|
||||
let count = mine.iter().filter(|d| &d.platform_key() == k).count();
|
||||
let driver = mine.iter().find(|d| &d.platform_key() == k).map(|d| version_tuple(&d.driver)).unwrap_or_default();
|
||||
(count, driver)
|
||||
};
|
||||
let winner = plats.iter().max_by(|a, b| score(a).cmp(&score(b))).cloned().unwrap_or_default();
|
||||
let identity = |d: &ClDevice, ordinal: usize| if d.bus.is_empty() { format!("{}#{ordinal}", d.name.to_ascii_lowercase()) } else { d.bus.clone() };
|
||||
let mut have: Vec<String> = Vec::new();
|
||||
let mut seen_codes: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
let mut ordinal = |d: &ClDevice| {
|
||||
let n = seen_codes.entry(format!("{}|{}", d.platform_key(), d.name.to_ascii_lowercase())).or_insert(0);
|
||||
*n += 1;
|
||||
*n
|
||||
};
|
||||
for d in mine.iter().filter(|d| d.platform_key() == winner) {
|
||||
let o = ordinal(d);
|
||||
have.push(identity(d, o));
|
||||
kept.push((*d).clone());
|
||||
}
|
||||
for d in mine.iter().filter(|d| d.platform_key() != winner) {
|
||||
let o = ordinal(d);
|
||||
let id = identity(d, o);
|
||||
if have.contains(&id) {
|
||||
dropped.push(format!("[{}] {} on {} ({}) is the same card as the one on {}: no worker", d.index, d.name, d.platform, d.platform_version, winner));
|
||||
} else {
|
||||
have.push(id);
|
||||
kept.push((*d).clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
kept.sort_by_key(|d| d.index.parse::<u64>().unwrap_or(u64::MAX));
|
||||
(kept, dropped)
|
||||
}
|
||||
|
||||
/// Keys without an index (it moves when a card arrives): vendor:code, "#2" and up for identical cards in list order.
|
||||
pub fn assign_keys(cards: &mut [CardState]) {
|
||||
let mut seen: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for c in cards.iter_mut() {
|
||||
if c.code.is_empty() {
|
||||
c.code = c.name.clone();
|
||||
}
|
||||
let base = format!("{}:{}", c.vendor, c.code);
|
||||
let n = seen.entry(base.clone()).or_insert(0);
|
||||
*n += 1;
|
||||
c.key = if *n == 1 { base } else { format!("{base}#{n}") };
|
||||
}
|
||||
}
|
||||
|
||||
/// What one Windows enumeration gathered; `assemble` turns it into the list (pure, so the PC 1 cases are tests).
|
||||
#[derive(Default)]
|
||||
pub struct Inputs {
|
||||
/// `nvidia-smi --query-gpu=index,name,memory.total,pci.bus_id --format=csv,noheader`; None = nvidia-smi did not run
|
||||
pub nvidia: Option<String>,
|
||||
pub nvidia_limits: std::collections::HashMap<String, (f64, f64, f64, f64)>,
|
||||
pub cuda_worker: bool,
|
||||
/// the OpenCL worker's --list; None = no worker installed or it did not answer
|
||||
pub opencl: Option<String>,
|
||||
pub opencl_installed: bool,
|
||||
/// Windows' adapter list; None = PowerShell did not answer
|
||||
pub adapters: Option<Vec<Adapter>>,
|
||||
}
|
||||
|
||||
pub fn assemble(inp: Inputs) -> Detection {
|
||||
let mut d = Detection::default();
|
||||
d.adapters_listed = inp.adapters.is_some();
|
||||
let adapters = inp.adapters.unwrap_or_default();
|
||||
let mut used: Vec<usize> = Vec::new();
|
||||
match inp.nvidia {
|
||||
Some(out) => {
|
||||
d.nvidia_listed = true;
|
||||
for line in out.lines() {
|
||||
let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect();
|
||||
if parts.len() >= 2 && parts[0].chars().all(|c| c.is_ascii_digit()) && !parts[0].is_empty() {
|
||||
let mem_mb: u64 = parts.get(2).and_then(|m| m.split_whitespace().next()).and_then(|n| n.parse::<f64>().ok()).map(|v| v as u64).unwrap_or(0);
|
||||
let detail = if mem_mb > 0 { format!("{} GB", (mem_mb + 512) / 1024) } else { String::new() };
|
||||
let worker_ok = bins.cuda.is_some();
|
||||
let mut c = card(cards.len(), parts[1], "nvidia", "CUDA", &detail, parts[0]);
|
||||
c.kind = if looks_integrated(parts[1]) { "integrated".into() } else { "discrete".into() };
|
||||
// nvidia-smi prints 00000000:01:00.0; the worker and Windows say 01:00.0
|
||||
let bus = parts.get(3).map(|b| b.trim().to_ascii_lowercase()).map(|b| b.rsplit_once(':').map(|(d, r)| format!("{}:{r}", d.rsplit(':').next().unwrap_or(d))).unwrap_or(b)).unwrap_or_default();
|
||||
let (name, adapter) = resolve_name(parts[1], "nvidia", &bus, &adapters, &mut used);
|
||||
let mut c = card(d.cards.len(), &name, "nvidia", "CUDA", &detail, parts[0]);
|
||||
c.code = parts[1].to_string();
|
||||
c.bus = bus;
|
||||
c.kind = classify_kind(parts[1], adapter.map(|i| &adapters[i])).into();
|
||||
c.vram_mb = mem_mb;
|
||||
c.path = if worker_ok { "prebuilt".into() } else { "build".into() };
|
||||
if !worker_ok {
|
||||
c.path = if inp.cuda_worker { "prebuilt".into() } else { "build".into() };
|
||||
if !inp.cuda_worker {
|
||||
c.message = "no prebuilt CUDA worker in the package; built from source on first run (needs the CUDA Toolkit and Visual Studio)".into();
|
||||
}
|
||||
apply_defaults(&mut c);
|
||||
cards.push(c);
|
||||
d.cards.push(c);
|
||||
}
|
||||
}
|
||||
if cards.is_empty() {
|
||||
notes.push("nvidia-smi ran but listed no card".into());
|
||||
if d.cards.is_empty() {
|
||||
d.notes.push("nvidia-smi ran but listed no card".into());
|
||||
}
|
||||
let limits = nvidia_power_limits();
|
||||
for c in cards.iter_mut() {
|
||||
if let Some((d, cur, lo, hi)) = limits.get(&c.device) {
|
||||
c.power_default_w = *d;
|
||||
for c in d.cards.iter_mut() {
|
||||
if let Some((dflt, cur, lo, hi)) = inp.nvidia_limits.get(&c.device) {
|
||||
c.power_default_w = *dflt;
|
||||
c.power_limit_w = *cur;
|
||||
c.power_before_w = *cur;
|
||||
c.power_min_w = *lo;
|
||||
|
|
@ -235,55 +620,84 @@ pub fn detect(bins: &Bins, notes: &mut Vec<String>) -> Vec<CardState> {
|
|||
mark_sweep_support(c);
|
||||
}
|
||||
}
|
||||
None => notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()),
|
||||
None => d.notes.push("nvidia-smi is not on this PC (no NVIDIA driver): no NVIDIA card".into()),
|
||||
}
|
||||
// OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped)
|
||||
if let Some(cl) = bins.opencl.as_ref() {
|
||||
if let Some(out) = run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15)) {
|
||||
let lines: Vec<&str> = out.lines().collect();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let t = line.trim_start_matches(|c| c == ' ' || c == '*').trim();
|
||||
if !t.starts_with('[') {
|
||||
continue;
|
||||
}
|
||||
let Some(close) = t.find(']') else { continue };
|
||||
let idx = &t[1..close];
|
||||
let rest = &t[close + 1..];
|
||||
let name = rest.split(" |").next().unwrap_or("").trim();
|
||||
let info = lines.get(i + 1).map(|l| l.trim()).unwrap_or("");
|
||||
let is_gpu = info.starts_with("GPU");
|
||||
let vendor_s = info.split("vendor ").nth(1).unwrap_or("").split(", driver").next().unwrap_or("").trim();
|
||||
if !is_gpu || name.contains("NVIDIA") || vendor_s.contains("NVIDIA") {
|
||||
continue;
|
||||
}
|
||||
let vendor = if vendor_s.contains("Advanced Micro") || name.contains("Radeon") || name.contains("AMD") { "amd" } else { "other" };
|
||||
let units = info.split(", ").find(|p| p.contains("compute units")).unwrap_or("").to_string();
|
||||
let mut c = card(cards.len(), name, vendor, "OpenCL", &units, idx);
|
||||
c.device = idx.to_string();
|
||||
c.kind = if looks_integrated(name) { "integrated".into() } else { "discrete".into() };
|
||||
c.path = "prebuilt".into();
|
||||
apply_defaults(&mut c);
|
||||
mark_sweep_support(&mut c);
|
||||
cards.push(c);
|
||||
}
|
||||
if let Some(out) = inp.opencl {
|
||||
let (devs, listed) = parse_opencl_list(&out);
|
||||
d.opencl_listed = listed;
|
||||
let (kept, dropped) = dedupe_platforms(devs.into_iter().filter(|dv| dv.vendor_word() != "nvidia").collect());
|
||||
d.dropped = dropped;
|
||||
for dv in kept {
|
||||
let vendor = dv.vendor_word();
|
||||
let (name, adapter) = resolve_name(&dv.name, vendor, &dv.bus, &adapters, &mut used);
|
||||
let mut c = card(d.cards.len(), &name, vendor, "OpenCL", &dv.units, &dv.index);
|
||||
c.code = dv.name.clone();
|
||||
c.bus = dv.bus.clone();
|
||||
c.platform = format!("{} ({}), driver {}", dv.platform, dv.platform_version, dv.driver);
|
||||
c.kind = classify_kind(&dv.name, adapter.map(|i| &adapters[i])).into();
|
||||
c.vram_mb = if c.kind == "integrated" { 0 } else { dv.mem_mb };
|
||||
c.path = "prebuilt".into();
|
||||
apply_defaults(&mut c);
|
||||
mark_sweep_support(&mut c);
|
||||
d.cards.push(c);
|
||||
}
|
||||
} else if cards.is_empty() {
|
||||
notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into());
|
||||
} else if !inp.opencl_installed && d.cards.is_empty() {
|
||||
d.notes.push("the OpenCL worker is not installed; AMD and Intel cards cannot be listed".into());
|
||||
}
|
||||
if cards.is_empty() {
|
||||
if d.cards.is_empty() {
|
||||
// last resort: the names Windows knows, so the screen can at least say what is in the PC
|
||||
if let Some(out) = run_timeout(Command::new(crate::platform::tool("powershell")).args(["-NoProfile", "-Command", "Get-CimInstance Win32_VideoController | ForEach-Object { $_.Name }"]), None, Duration::from_secs(15)) {
|
||||
for n in out.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
|
||||
let vendor = if n.contains("NVIDIA") { "nvidia" } else if n.contains("AMD") || n.contains("Radeon") { "amd" } else { "other" };
|
||||
let mut c = card(cards.len(), n, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "0");
|
||||
c.kind = if looks_integrated(n) { "integrated".into() } else { "unknown".into() };
|
||||
c.enabled = false;
|
||||
c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into();
|
||||
cards.push(c);
|
||||
}
|
||||
for (i, a) in adapters.iter().enumerate().filter(|(_, a)| a.problem().is_none()) {
|
||||
let vendor = vendor_of(&a.name);
|
||||
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
|
||||
c.bus = a.bus.clone();
|
||||
c.kind = if looks_integrated(&a.name) { "integrated".into() } else { "unknown".into() };
|
||||
c.enabled = false;
|
||||
c.reason = "seen by Windows, but no worker can drive it (no NVIDIA driver and no OpenCL worker)".into();
|
||||
used.push(i);
|
||||
d.cards.push(c);
|
||||
}
|
||||
}
|
||||
cards
|
||||
// the cards Windows lists with a problem (Code 43 after an eGPU hot-plug on PC 1, 5 October 2026): shown, never driven
|
||||
for (i, a) in adapters.iter().enumerate() {
|
||||
let Some(problem) = a.problem() else { continue };
|
||||
if used.contains(&i) || d.cards.iter().any(|c| c.name.trim().eq_ignore_ascii_case(a.name.trim())) {
|
||||
continue;
|
||||
}
|
||||
let vendor = vendor_of(&a.name);
|
||||
let mut c = card(d.cards.len(), &a.name, vendor, if vendor == "nvidia" { "CUDA" } else { "OpenCL" }, "", "");
|
||||
c.bus = a.bus.clone();
|
||||
c.kind = classify_kind(&a.name, Some(a)).into();
|
||||
mark_unusable(&mut c, &problem);
|
||||
d.cards.push(c);
|
||||
}
|
||||
assign_keys(&mut d.cards);
|
||||
d
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn detect(bins: &Bins) -> Detection {
|
||||
// Windows' own view of every adapter: status and problem code (a Code 43 card is in no tool's list), memory and
|
||||
// processor for the integrated call, the PCI address and the names for the rows
|
||||
let adapters = adapters();
|
||||
// NVIDIA: nvidia-smi ships with the driver
|
||||
let nvidia = run_timeout(Command::new(crate::platform::tool("nvidia-smi")).args(["--query-gpu=index,name,memory.total,pci.bus_id", "--format=csv,noheader"]), None, Duration::from_secs(10));
|
||||
let nvidia_limits = if nvidia.is_some() { nvidia_power_limits() } else { Default::default() };
|
||||
// OpenCL: the worker's own device list (AMD, Intel; NVIDIA shows there too and is skipped)
|
||||
let opencl = bins.opencl.as_ref().and_then(|cl| run_timeout(Command::new(cl).arg("--list"), None, Duration::from_secs(15)));
|
||||
assemble(Inputs { nvidia, nvidia_limits, cuda_worker: bins.cuda.is_some(), opencl, opencl_installed: bins.opencl.is_some(), adapters })
|
||||
}
|
||||
|
||||
/// A listed card no worker can drive: off, no switch, the problem on the row and the hint under it.
|
||||
pub fn mark_unusable(c: &mut CardState, problem: &str) {
|
||||
c.problem = problem.to_string();
|
||||
c.enabled = false;
|
||||
c.identities = 1;
|
||||
c.state = "unusable".into();
|
||||
c.message = format!("not usable ({problem})");
|
||||
c.reason = PROBLEM_HINT.into();
|
||||
c.sweep_supported = false;
|
||||
c.sweep_state = "unsupported".into();
|
||||
c.sweep_note = c.message.clone();
|
||||
}
|
||||
|
||||
/// Finds the binaries next to the engine (Windows, a plain folder) or in Contents/Resources/bin (macOS bundle).
|
||||
|
|
@ -322,3 +736,250 @@ pub fn node_version(node: &Path) -> String {
|
|||
.and_then(|o| o.lines().next().map(|l| l.trim().to_string()))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// PC 1's adapter list on the evening of 5 October 2026, after the RX 9070 XT went in through the Sonnet eGPU box
|
||||
// while the app ran: "AMD Radeon RX 9070 XT | status Error | ram 0 GB", "AMD Radeon(TM) Graphics | status OK |
|
||||
// ram 2 GB" (the Ryzen iGPU, gfx1036 to OpenCL), "NVIDIA GeForce RTX 5090 | status OK".
|
||||
fn pc1() -> Vec<Adapter> {
|
||||
parse_adapters(r#"[{"Name":"AMD Radeon RX 9070 XT","Status":"Error","ConfigManagerErrorCode":43,"AdapterRAM":0,"VideoProcessor":"AMD Radeon Graphics Processor (0x7550)","PNPDeviceID":"PCI\\VEN_1002&DEV_7550&SUBSYS_0E4E1002&REV_C0\\6&1A2B3C4D&0&00000008"},
|
||||
{"Name":"AMD Radeon(TM) Graphics","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":2147483648,"VideoProcessor":"AMD Radeon Graphics Processor (0x164E)","PNPDeviceID":"PCI\\VEN_1002&DEV_164E&SUBSYS_00000000&REV_C1\\4&2E5A1B3&0&0041"},
|
||||
{"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE&DEV_2B85&SUBSYS_10621043&REV_A1\\4&1F2E3D4C&0&0019"}]"#)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adapters_parse_with_status_code_and_memory() {
|
||||
let a = pc1();
|
||||
assert_eq!(a.len(), 3);
|
||||
assert_eq!(a[0].name, "AMD Radeon RX 9070 XT");
|
||||
assert_eq!(a[0].status, "Error");
|
||||
assert_eq!(a[0].code, 43);
|
||||
assert_eq!(a[0].ram_mb, 0);
|
||||
assert_eq!(a[0].problem().as_deref(), Some("Code 43"));
|
||||
assert_eq!(a[1].ram_mb, 2048);
|
||||
assert_eq!(a[1].problem(), None);
|
||||
assert_eq!(a[2].problem(), None);
|
||||
// a single adapter: ConvertTo-Json gives one object, not an array
|
||||
let one = parse_adapters(r#"{"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE"}"#);
|
||||
assert_eq!(one.len(), 1);
|
||||
assert!(parse_adapters("not json").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn problem_without_a_code_is_the_status_word() {
|
||||
let a = Adapter { name: "x".into(), status: "Degraded".into(), ..Default::default() };
|
||||
assert_eq!(a.problem().as_deref(), Some("status Degraded"));
|
||||
let fine = Adapter { name: "x".into(), status: "OK".into(), ..Default::default() };
|
||||
assert_eq!(fine.problem(), None);
|
||||
let code12 = Adapter { name: "x".into(), status: "Error".into(), code: 12, ..Default::default() };
|
||||
assert_eq!(code12.problem().as_deref(), Some("Code 12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_from_pc1_lines_and_the_mac() {
|
||||
let a = pc1();
|
||||
// the discrete cards, with and without their adapter row
|
||||
assert_eq!(classify_kind("AMD Radeon RX 9070 XT", adapter_for("AMD Radeon RX 9070 XT", &a)), "discrete");
|
||||
assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", adapter_for("NVIDIA GeForce RTX 5090", &a)), "discrete");
|
||||
assert_eq!(classify_kind("NVIDIA GeForce RTX 5090", None), "discrete");
|
||||
// the Ryzen iGPU: by its Windows name, and by the gfx code the OpenCL worker prints (no adapter row matches a code)
|
||||
assert_eq!(classify_kind("AMD Radeon(TM) Graphics", adapter_for("AMD Radeon(TM) Graphics", &a)), "integrated");
|
||||
assert_eq!(classify_kind("gfx1036", adapter_for("gfx1036", &a)), "integrated");
|
||||
assert_eq!(classify_kind("gfx1036", None), "integrated");
|
||||
assert_eq!(classify_kind("gfx1036:xnack-", None), "integrated");
|
||||
// a discrete gfx code stays discrete; an Arc card is discrete despite "Intel"
|
||||
assert_eq!(classify_kind("gfx1201", None), "discrete");
|
||||
assert_eq!(classify_kind("gfx1030", None), "discrete");
|
||||
assert_eq!(classify_kind("Intel(R) Arc(TM) A770 Graphics", None), "discrete");
|
||||
// an Intel iGPU by its processor string; an AMD discrete card's processor string ("AMD Radeon Graphics Processor") does not count
|
||||
let intel = Adapter { name: "Intel(R) Iris(R) Xe Graphics".into(), processor: "Intel(R) Iris(R) Xe Graphics Family".into(), ram_mb: 1024, ..Default::default() };
|
||||
assert_eq!(classify_kind("Intel(R) Iris(R) Xe Graphics", Some(&intel)), "integrated");
|
||||
assert_eq!(a[0].processor, "AMD Radeon Graphics Processor (0x7550)");
|
||||
// shared memory under 1 GB on the adapter row makes an unknown name integrated; 0 says nothing
|
||||
let small = Adapter { name: "Some iGPU".into(), ram_mb: 512, ..Default::default() };
|
||||
assert_eq!(classify_kind("Some iGPU", Some(&small)), "integrated");
|
||||
let unknown = Adapter { name: "Some card".into(), ram_mb: 0, ..Default::default() };
|
||||
assert_eq!(classify_kind("Some card", Some(&unknown)), "discrete");
|
||||
// the Mac: detect() labels Apple silicon "apple" itself; the name rules do not call it integrated
|
||||
assert!(!looks_integrated("Apple M5 Max"));
|
||||
assert_eq!(vendor_of("Apple M5 Max"), "apple");
|
||||
assert_eq!(vendor_of("gfx1036"), "amd");
|
||||
assert_eq!(vendor_of("AMD Radeon RX 9070 XT"), "amd");
|
||||
assert_eq!(vendor_of("NVIDIA GeForce RTX 5090"), "nvidia");
|
||||
}
|
||||
|
||||
// PC 1 after Adrenalin 26.9.2 and a reboot (5 October 2026, evening): all three adapters OK, the Windows names,
|
||||
// DEV ids and PCI addresses as the coordinator read them (the 5090's bus 01:00.0; the two AMD cards' addresses are
|
||||
// not in that reading, so this fixture leaves them empty, as an old worker's --list would)
|
||||
fn pc1_rebooted() -> Vec<Adapter> {
|
||||
parse_adapters(r#"[{"Name":"AMD Radeon(TM) Graphics","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":2147483648,"VideoProcessor":"AMD Radeon Graphics Processor (0x13C0)","PNPDeviceID":"PCI\\VEN_1002&DEV_13C0&SUBSYS_00000000&REV_C1\\4&2E5A1B3&0&0041","BusNumber":null,"Address":null},
|
||||
{"Name":"NVIDIA GeForce RTX 5090","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"NVIDIA GeForce RTX 5090","PNPDeviceID":"PCI\\VEN_10DE&DEV_2B85&SUBSYS_10621043&REV_A1\\4&1F2E3D4C&0&0019","BusNumber":1,"Address":0},
|
||||
{"Name":"AMD Radeon RX 9070 XT","Status":"OK","ConfigManagerErrorCode":0,"AdapterRAM":4293918720,"VideoProcessor":"AMD Radeon Graphics Processor (0x7550)","PNPDeviceID":"PCI\\VEN_1002&DEV_7550&SUBSYS_0E4E1002&REV_C0\\6&1A2B3C4D&0&00000008","BusNumber":null,"Address":null}]"#)
|
||||
}
|
||||
// the 0.3.9 app's five rows came from this shape of --list: two AMD platforms, each listing both AMD GPUs (the old
|
||||
// 32.0.21042 ICD and the new 32.0.32015 one); the driver strings are the shape AMD's runtime prints, the numbers
|
||||
// are the Windows driver builds (approximate: the OpenCL CL_DRIVER_VERSION was not captured)
|
||||
const PC1_LIST: &str = "OpenCL devices (4):\n\
|
||||
[0] gfx1036 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n\
|
||||
GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 2 compute units, 2200 MHz\n\
|
||||
global 16384 MiB, max alloc 13926 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n\
|
||||
[1] gfx1201 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n\
|
||||
GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 32 compute units, 2970 MHz\n\
|
||||
global 16368 MiB, max alloc 13912 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n\
|
||||
[2] gfx1036 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3649.0))\n\
|
||||
GPU, vendor Advanced Micro Devices, Inc., driver 3649.0 (PAL,HSAIL), OpenCL C 2.0, 2 compute units, 2200 MHz\n\
|
||||
global 16384 MiB, max alloc 13926 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n\
|
||||
[3] gfx1201 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3649.0))\n\
|
||||
GPU, vendor Advanced Micro Devices, Inc., driver 3649.0 (PAL,HSAIL), OpenCL C 2.0, 32 compute units, 2970 MHz\n\
|
||||
global 16368 MiB, max alloc 13912 MiB, local 64 KiB, max work-group 256, sub-group extension: cl_khr_subgroups (no shuffle extension), AMD wavefront width 32\n";
|
||||
const PC1_SMI: &str = "0, NVIDIA GeForce RTX 5090, 32607 MiB, 00000000:01:00.0\n";
|
||||
|
||||
fn pc1_inputs(list: &str) -> Inputs {
|
||||
Inputs { nvidia: Some(PC1_SMI.into()), nvidia_limits: Default::default(), cuda_worker: true, opencl: Some(list.into()), opencl_installed: true, adapters: Some(pc1_rebooted()) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opencl_list_parses_both_platforms_and_the_pci_field() {
|
||||
let (devs, listed) = parse_opencl_list(PC1_LIST);
|
||||
assert!(listed);
|
||||
assert_eq!(devs.len(), 4);
|
||||
assert_eq!(devs[1].index, "1");
|
||||
assert_eq!(devs[1].name, "gfx1201");
|
||||
assert_eq!(devs[1].platform, "AMD Accelerated Parallel Processing");
|
||||
assert_eq!(devs[1].platform_version, "OpenCL 2.1 AMD-APP (3617.0)");
|
||||
assert_eq!(devs[1].driver, "3617.0 (PAL,HSAIL)");
|
||||
assert_eq!(devs[1].units, "32 compute units");
|
||||
assert_eq!(devs[1].mem_mb, 16368);
|
||||
assert_eq!(devs[1].bus, "");
|
||||
assert!(devs[1].is_gpu);
|
||||
assert_eq!(devs[1].vendor_word(), "amd");
|
||||
let with_pci = PC1_LIST.replace("32 compute units, 2970 MHz\n", "32 compute units, 2970 MHz, pci 05:00.0\n");
|
||||
let (devs, _) = parse_opencl_list(&with_pci);
|
||||
assert_eq!(devs[1].bus, "05:00.0");
|
||||
assert_eq!(devs[3].bus, "05:00.0");
|
||||
assert!(!parse_opencl_list("").1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_amd_platforms_give_one_entry_per_card() {
|
||||
// without PCI addresses: by code and ordinal, the newer driver wins
|
||||
let (devs, _) = parse_opencl_list(PC1_LIST);
|
||||
let (kept, dropped) = dedupe_platforms(devs);
|
||||
assert_eq!(kept.iter().map(|d| d.index.as_str()).collect::<Vec<_>>(), vec!["2", "3"]);
|
||||
assert_eq!(dropped.len(), 2);
|
||||
assert!(dropped[0].starts_with("[0] gfx1036 on AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0)) is the same card as"), "{}", dropped[0]);
|
||||
// with PCI addresses: by address; a card the winner does not list (the old ICD still serving a third card) is kept
|
||||
let text = PC1_LIST
|
||||
.replace("2 compute units, 2200 MHz\n", "2 compute units, 2200 MHz, pci 0c:00.0\n")
|
||||
.replace("32 compute units, 2970 MHz\n", "32 compute units, 2970 MHz, pci 05:00.0\n")
|
||||
+ " [4] gfx1100 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 96 compute units, 2500 MHz, pci 09:00.0\n global 24560 MiB\n";
|
||||
let (devs, _) = parse_opencl_list(&text);
|
||||
let (kept, dropped) = dedupe_platforms(devs);
|
||||
// the old platform now lists three and wins on count: its three stay, the new platform's two are duplicates
|
||||
assert_eq!(kept.iter().map(|d| d.index.as_str()).collect::<Vec<_>>(), vec!["0", "1", "4"]);
|
||||
assert_eq!(dropped.len(), 2);
|
||||
// two real twins on one platform keep both entries (same code, different ordinal or address)
|
||||
let twins = "OpenCL devices (2):\n [0] gfx1201 | P (v)\n GPU, vendor Advanced Micro Devices, Inc., driver 1.0, OpenCL C 2.0, 32 compute units, 2970 MHz\n [1] gfx1201 | P (v)\n GPU, vendor Advanced Micro Devices, Inc., driver 1.0, OpenCL C 2.0, 32 compute units, 2970 MHz\n";
|
||||
let (kept, dropped) = dedupe_platforms(parse_opencl_list(twins).0);
|
||||
assert_eq!(kept.len(), 2);
|
||||
assert!(dropped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_come_from_windows_by_bus_then_device_id_then_the_table() {
|
||||
let a = pc1_rebooted();
|
||||
let mut used = Vec::new();
|
||||
assert_eq!(resolve_name("NVIDIA GeForce RTX 5090", "nvidia", "01:00.0", &a, &mut used).0, "NVIDIA GeForce RTX 5090");
|
||||
assert_eq!(resolve_name("gfx1201", "amd", "", &a, &mut used).0, "AMD Radeon RX 9070 XT");
|
||||
assert_eq!(resolve_name("gfx1036", "amd", "", &a, &mut used).0, "AMD Radeon(TM) Graphics");
|
||||
assert_eq!(used.len(), 3);
|
||||
// every adapter is taken: a second gfx1036 gets the table's words, a code the table lacks stays a code
|
||||
assert_eq!(resolve_name("gfx1036", "amd", "", &a, &mut used).0, "Ryzen integrated Radeon Graphics");
|
||||
assert_eq!(resolve_name("gfx9999", "amd", "", &a, &mut used).0, "gfx9999");
|
||||
assert_eq!(resolve_name("gfx1100", "amd", "", &[], &mut Vec::new()).0, "Radeon RX 7900 XTX / XT");
|
||||
// by PCI address when both sides have one, before any table
|
||||
let mut b = pc1_rebooted();
|
||||
b[2].bus = "05:00.0".into();
|
||||
let mut used = Vec::new();
|
||||
assert_eq!(resolve_name("gfx1201", "amd", "05:00.0", &b, &mut used), ("AMD Radeon RX 9070 XT".to_string(), Some(2)));
|
||||
// the device id alone names a card whose adapter name the table does not know
|
||||
let mut c = pc1_rebooted();
|
||||
c[2].name = "AMD Radeon RX 9070 XT OC Edition".into();
|
||||
assert_eq!(resolve_name("gfx1201", "amd", "", &c, &mut Vec::new()).0, "AMD Radeon RX 9070 XT OC Edition");
|
||||
assert_eq!(a[2].device_id(), 0x7550);
|
||||
assert_eq!(a[0].device_id(), 0x13C0);
|
||||
assert_eq!(a[1].bus, "01:00.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pc1_five_rows_become_three_cards_named_properly() {
|
||||
let d = assemble(pc1_inputs(PC1_LIST));
|
||||
assert!(d.nvidia_listed && d.opencl_listed && d.adapters_listed);
|
||||
let rows: Vec<(String, String, String, String, bool, String)> = d.cards.iter().map(|c| (c.name.clone(), c.key.clone(), c.kind.clone(), c.device.clone(), c.enabled, c.code.clone())).collect();
|
||||
assert_eq!(rows, vec![
|
||||
("NVIDIA GeForce RTX 5090".into(), "nvidia:NVIDIA GeForce RTX 5090".into(), "discrete".into(), "0".into(), true, "NVIDIA GeForce RTX 5090".into()),
|
||||
("AMD Radeon(TM) Graphics".into(), "amd:gfx1036".into(), "integrated".into(), "2".into(), false, "gfx1036".into()),
|
||||
("AMD Radeon RX 9070 XT".into(), "amd:gfx1201".into(), "discrete".into(), "3".into(), true, "gfx1201".into()),
|
||||
]);
|
||||
assert_eq!(d.cards[0].bus, "01:00.0");
|
||||
assert_eq!(d.cards[1].reason, INTEGRATED_REASON);
|
||||
assert_eq!(d.cards[1].identities, 1);
|
||||
assert_eq!(d.cards[2].identities, 8, "16 GB: 8 identities");
|
||||
assert_eq!(d.cards[2].vram_mb, 16368);
|
||||
assert!(d.cards[2].platform.contains("3649.0"));
|
||||
assert_eq!(d.dropped.len(), 2);
|
||||
assert!(d.notes.is_empty(), "{:?}", d.notes);
|
||||
assert_eq!(crate::hotplug::cards_line(&d.cards), "cards: NVIDIA GeForce RTX 5090 [discrete, off] | AMD Radeon(TM) Graphics [integrated, off] | AMD Radeon RX 9070 XT [discrete, off]");
|
||||
// the same machine before the eGPU: one platform, the iGPU alone; the keys do not depend on the index
|
||||
let before = "OpenCL devices (1):\n [0] gfx1036 | AMD Accelerated Parallel Processing (OpenCL 2.1 AMD-APP (3617.0))\n GPU, vendor Advanced Micro Devices, Inc., driver 3617.0 (PAL,HSAIL), OpenCL C 2.0, 2 compute units, 2200 MHz\n global 16384 MiB\n";
|
||||
let d0 = assemble(pc1_inputs(before));
|
||||
assert_eq!(d0.cards[1].key, "amd:gfx1036");
|
||||
assert_eq!(d0.cards[1].device, "0");
|
||||
// and the diff between the two lists: the iGPU moved (device 0 to 2), the 9070 XT is new, nothing is removed
|
||||
let diff = crate::hotplug::diff(&d0.cards, &d.cards, &|c| d.listed(c));
|
||||
assert_eq!(diff.unchanged, vec![0]);
|
||||
assert_eq!(diff.moved.len(), 1);
|
||||
assert_eq!(diff.moved[0].0, 1);
|
||||
assert_eq!(diff.added.len(), 1);
|
||||
assert_eq!(diff.added[0].name, "AMD Radeon RX 9070 XT");
|
||||
assert!(diff.removed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_number_identical_cards() {
|
||||
let mut cards = vec![card(0, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "", "0"), card(1, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "", "1"), card(2, "gfx1201", "amd", "OpenCL", "", "2")];
|
||||
cards[2].name = "AMD Radeon RX 9070 XT".into();
|
||||
assign_keys(&mut cards);
|
||||
assert_eq!(cards.iter().map(|c| c.key.as_str()).collect::<Vec<_>>(), vec!["nvidia:NVIDIA GeForce RTX 5090", "nvidia:NVIDIA GeForce RTX 5090#2", "amd:gfx1201"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unusable_card_row() {
|
||||
let mut c = card(2, "AMD Radeon RX 9070 XT", "amd", "OpenCL", "", "");
|
||||
mark_unusable(&mut c, "Code 43");
|
||||
assert!(!c.enabled);
|
||||
assert_eq!(c.state, "unusable");
|
||||
assert_eq!(c.message, "not usable (Code 43)");
|
||||
assert_eq!(c.reason, PROBLEM_HINT);
|
||||
assert!(!c.present());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integrated_default_is_off_with_the_row_words() {
|
||||
let mut c = card(1, "gfx1036", "amd", "OpenCL", "2 compute units", "1");
|
||||
c.kind = classify_kind("gfx1036", None).into();
|
||||
apply_defaults(&mut c);
|
||||
assert!(!c.enabled);
|
||||
assert_eq!(c.identities, 1);
|
||||
assert_eq!(c.reason, INTEGRATED_REASON);
|
||||
let mut big = card(0, "NVIDIA GeForce RTX 5090", "nvidia", "CUDA", "32 GB", "0");
|
||||
big.kind = "discrete".into();
|
||||
big.vram_mb = 32768;
|
||||
apply_defaults(&mut big);
|
||||
assert!(big.enabled);
|
||||
assert_eq!(big.identities, 8);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,8 @@ pub struct CardChoice {
|
|||
pub enum Cmd {
|
||||
Detect,
|
||||
ApplyCards(Vec<CardChoice>),
|
||||
Detected(Vec<CardState>, Vec<String>),
|
||||
/// one enumeration of the cards finished (the first, or a re-detection: src/hotplug.rs)
|
||||
Detected(crate::detect::Detection),
|
||||
Start,
|
||||
Pause,
|
||||
Resume,
|
||||
|
|
@ -350,6 +351,40 @@ fn address_state(s: &Settings, wallet_path: &std::path::Path) -> crate::state::A
|
|||
}
|
||||
}
|
||||
|
||||
/// The node's consensus params digest out of its own line ("Consensus params digest: <64 hex> (exchanged in the p2p
|
||||
/// handshake; ...)"). None for any other line, including the WARN lines that quote a peer's digest.
|
||||
pub fn digest_from_line(line: &str) -> Option<String> {
|
||||
let rest = line.split("Consensus params digest:").nth(1)?.trim_start();
|
||||
let hex: String = rest.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
|
||||
(hex.len() == 64).then(|| hex.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
/// The planned rule changes in an override file: every `<name>_activation_daa` key, lowest height first, with plain
|
||||
/// words for the name ("fees_v1" -> "Fees v1"). Keys that are not activation heights are left out.
|
||||
pub fn switches_of(v: &Value) -> Vec<crate::state::ConsensusSwitch> {
|
||||
let mut out: Vec<crate::state::ConsensusSwitch> = v
|
||||
.as_object()
|
||||
.map(|o| {
|
||||
o.iter()
|
||||
.filter_map(|(k, val)| {
|
||||
let stem = k.strip_suffix("_activation_daa")?;
|
||||
let daa = val.as_u64()?;
|
||||
let mut words: Vec<String> = stem.split('_').map(|w| w.to_string()).collect();
|
||||
if let Some(first) = words.first_mut() {
|
||||
let mut c = first.chars();
|
||||
if let Some(f) = c.next() {
|
||||
*first = f.to_ascii_uppercase().to_string() + c.as_str();
|
||||
}
|
||||
}
|
||||
Some(crate::state::ConsensusSwitch { key: k.clone(), name: words.join(" "), daa })
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
out.sort_by_key(|s| (s.daa, s.key.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
/// One value out of `key=value` tokens of a log line.
|
||||
fn kv<'a>(line: &'a str, key: &str) -> Option<&'a str> {
|
||||
let pat = format!(" {key}=");
|
||||
|
|
@ -438,6 +473,12 @@ pub struct Engine {
|
|||
wrapper: bool,
|
||||
last_state_print: Instant,
|
||||
detected: bool,
|
||||
/// hot-plug (src/hotplug.rs): an enumeration thread is running; one more was asked for meanwhile; when the
|
||||
/// next periodic one is due; when the app log last carried the card list
|
||||
detect_busy: bool,
|
||||
detect_again: bool,
|
||||
detect_next: Instant,
|
||||
last_cards_line: Instant,
|
||||
clock_samples: Vec<f64>,
|
||||
clock_node_at: Option<Instant>,
|
||||
clock_node_behind: f64,
|
||||
|
|
@ -532,6 +573,10 @@ impl Engine {
|
|||
wrapper,
|
||||
last_state_print: now,
|
||||
detected: false,
|
||||
detect_busy: false,
|
||||
detect_again: false,
|
||||
detect_next: now + Duration::from_secs(crate::hotplug::POLL_S),
|
||||
last_cards_line: now,
|
||||
clock_samples: Vec::new(),
|
||||
clock_node_at: None,
|
||||
clock_node_behind: 0.0,
|
||||
|
|
@ -637,62 +682,34 @@ impl Engine {
|
|||
fn command(&mut self, c: Cmd) {
|
||||
match c {
|
||||
Cmd::Detect => {
|
||||
if self.detected {
|
||||
// the first run, /api/detect, the host's "detect" on a device change, and the minute poll all land
|
||||
// here; one enumeration at a time, and a request during one runs once more after it
|
||||
if self.detect_busy {
|
||||
self.detect_again = true;
|
||||
return;
|
||||
}
|
||||
self.st().detecting = true;
|
||||
self.detect_busy = true;
|
||||
if !self.detected {
|
||||
self.st().detecting = true;
|
||||
}
|
||||
let bins = self.bins.clone();
|
||||
let shared = self.shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut notes = Vec::new();
|
||||
let cards = crate::detect::detect(&bins, &mut notes);
|
||||
shared.send(Cmd::Detected(cards, notes));
|
||||
let d = crate::detect::detect(&bins);
|
||||
shared.send(Cmd::Detected(d));
|
||||
});
|
||||
}
|
||||
Cmd::Detected(cards, notes) => {
|
||||
self.detected = true;
|
||||
let names: Vec<String> = cards.iter().map(|c| format!("{} ({})", c.name, c.worker)).collect();
|
||||
self.shared.log(&format!("GPUs: {}", if names.is_empty() { "none usable".to_string() } else { names.join("; ") }));
|
||||
for n in ¬es {
|
||||
self.shared.log(&format!("detection: {n}"));
|
||||
Cmd::Detected(d) => {
|
||||
self.detect_busy = false;
|
||||
self.detect_next = Instant::now() + Duration::from_secs(crate::hotplug::POLL_S);
|
||||
if self.detected {
|
||||
self.merge_detection(d);
|
||||
} else {
|
||||
self.first_detection(d);
|
||||
}
|
||||
let prefs = self.shared.settings.lock().unwrap().cards.clone();
|
||||
let mut st = self.st();
|
||||
st.detecting = false;
|
||||
st.detect_message = notes.join(". ");
|
||||
st.mining.cards = cards;
|
||||
for c in st.mining.cards.iter_mut() {
|
||||
if let Some(p) = prefs.get(&c.key) {
|
||||
c.enabled = p.enabled && c.kind != "unknown";
|
||||
c.identities = p.identities.clamp(1, 64);
|
||||
c.reason = String::new();
|
||||
if c.vendor == "nvidia" && p.power_pct > 0 {
|
||||
c.power_pct = p.power_pct.clamp(crate::sweep::MIN_PCT, 100);
|
||||
}
|
||||
c.pinned = p.pinned;
|
||||
c.sweep_pct = p.sweep_pct;
|
||||
c.sweep_eff = p.sweep_eff;
|
||||
c.sweep_watts = p.sweep_watts;
|
||||
c.sweep_mhs = p.sweep_mhs;
|
||||
c.sweep_at = p.sweep_at as f64;
|
||||
}
|
||||
}
|
||||
let supported: Vec<String> = st.mining.cards.iter().filter(|c| c.sweep_supported && c.enabled).map(|c| c.key.clone()).collect();
|
||||
let unsupported: Vec<String> = st.mining.cards.iter().filter(|c| !c.sweep_supported).map(|c| format!("{}: {}", c.name, c.sweep_note)).collect();
|
||||
drop(st);
|
||||
for u in &unsupported {
|
||||
self.shared.log(&format!("efficiency sweep {u}"));
|
||||
}
|
||||
if self.shared.runtime.sweep_only {
|
||||
if supported.is_empty() {
|
||||
self.sweep_say("SWEEP none reason=no_supported_card");
|
||||
self.shared.send(Cmd::Quit);
|
||||
} else {
|
||||
self.sweep_queue = supported;
|
||||
}
|
||||
}
|
||||
if self.running {
|
||||
self.plan_miners();
|
||||
if self.detect_again {
|
||||
self.detect_again = false;
|
||||
self.shared.send(Cmd::Detect);
|
||||
}
|
||||
}
|
||||
Cmd::ApplyCards(choices) => self.apply_cards(choices),
|
||||
|
|
@ -852,7 +869,7 @@ impl Engine {
|
|||
let mut missing = Vec::new();
|
||||
{
|
||||
let mut st = self.st();
|
||||
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0) {
|
||||
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0) {
|
||||
let want = requested_watts(c);
|
||||
let got = readback.get(&c.device).copied().unwrap_or(c.power_limit_w);
|
||||
if got > 0.0 {
|
||||
|
|
@ -973,6 +990,147 @@ impl Engine {
|
|||
|
||||
// ---- start, node ------------------------------------------------------------------------------------------
|
||||
|
||||
/// The first enumeration: the list as detected, the saved choices applied, the setup screen's notes.
|
||||
fn first_detection(&mut self, d: crate::detect::Detection) {
|
||||
self.detected = true;
|
||||
let crate::detect::Detection { cards, notes, dropped, .. } = d;
|
||||
let names: Vec<String> = cards.iter().map(|c| format!("{} ({}{}{})", c.name, c.worker, if c.code != c.name { format!(", {}", c.code) } else { String::new() }, if c.problem.is_empty() { String::new() } else { format!(", {}", c.problem) })).collect();
|
||||
self.shared.log(&format!("GPUs: {}", if names.is_empty() { "none usable".to_string() } else { names.join("; ") }));
|
||||
for n in ¬es {
|
||||
self.shared.log(&format!("detection: {n}"));
|
||||
}
|
||||
for n in &dropped {
|
||||
self.shared.log(&format!("detection: duplicate OpenCL platform entry {n}"));
|
||||
}
|
||||
let prefs = self.shared.settings.lock().unwrap().cards.clone();
|
||||
let mut st = self.st();
|
||||
st.detecting = false;
|
||||
st.detect_message = notes.join(". ");
|
||||
st.mining.cards = cards;
|
||||
for c in st.mining.cards.iter_mut() {
|
||||
if let Some(p) = crate::hotplug::pref_for(&prefs, c) {
|
||||
crate::hotplug::apply_pref(c, p);
|
||||
}
|
||||
}
|
||||
let supported: Vec<String> = st.mining.cards.iter().filter(|c| c.sweep_supported && c.enabled).map(|c| c.key.clone()).collect();
|
||||
let unsupported: Vec<String> = st.mining.cards.iter().filter(|c| !c.sweep_supported).map(|c| format!("{}: {}", c.name, c.sweep_note)).collect();
|
||||
let line = crate::hotplug::cards_line(&st.mining.cards);
|
||||
drop(st);
|
||||
self.shared.log(&line);
|
||||
self.last_cards_line = Instant::now();
|
||||
for u in &unsupported {
|
||||
self.shared.log(&format!("efficiency sweep {u}"));
|
||||
}
|
||||
if self.shared.runtime.sweep_only {
|
||||
if supported.is_empty() {
|
||||
self.sweep_say("SWEEP none reason=no_supported_card");
|
||||
self.shared.send(Cmd::Quit);
|
||||
} else {
|
||||
self.sweep_queue = supported;
|
||||
}
|
||||
}
|
||||
if self.running {
|
||||
self.plan_miners();
|
||||
}
|
||||
}
|
||||
|
||||
/// A later enumeration (src/hotplug.rs): new cards start, lost cards stop, faulty cards are marked; a list
|
||||
/// that changed nothing touches nothing (no running worker ever restarts because of a re-detection).
|
||||
fn merge_detection(&mut self, d: crate::detect::Detection) {
|
||||
let now = crate::platform::unix_now_f();
|
||||
let old = self.st().mining.cards.clone();
|
||||
let diff = crate::hotplug::diff(&old, &d.cards, &|c| d.listed(c));
|
||||
if diff.is_quiet() {
|
||||
return;
|
||||
}
|
||||
for n in &d.dropped {
|
||||
self.shared.log(&format!("detection: duplicate OpenCL platform entry {n}"));
|
||||
}
|
||||
let prefs = self.shared.settings.lock().unwrap().cards.clone();
|
||||
let mut new_nvidia = false;
|
||||
for i in diff.removed.iter().copied() {
|
||||
let name = old[i].name.clone();
|
||||
self.drop_worker(i, "the card was removed");
|
||||
if let Some(c) = self.st().mining.cards.get_mut(i) {
|
||||
crate::hotplug::mark_removed(c, now);
|
||||
}
|
||||
self.shared.event("warn", &format!("Card removed: {name}; its worker stopped"));
|
||||
}
|
||||
for (i, problem) in diff.errored.iter() {
|
||||
let name = old[*i].name.clone();
|
||||
self.drop_worker(*i, &format!("the card reports {problem}"));
|
||||
if let Some(c) = self.st().mining.cards.get_mut(*i) {
|
||||
crate::detect::mark_unusable(c, problem);
|
||||
c.added_at = now;
|
||||
}
|
||||
self.shared.event("warn", &format!("{name}: not usable ({problem}); its worker stopped. {}", crate::detect::PROBLEM_HINT));
|
||||
}
|
||||
for (i, fresh) in diff.moved.iter() {
|
||||
if let Some(c) = self.st().mining.cards.get_mut(*i) {
|
||||
self.shared.log(&format!("{}: device {} is now {} (the running worker keeps its device; the next start uses the new one)", c.name, c.device, fresh.device));
|
||||
c.device = fresh.device.clone();
|
||||
c.key = fresh.key.clone();
|
||||
c.bus = fresh.bus.clone();
|
||||
c.name = fresh.name.clone();
|
||||
c.platform = fresh.platform.clone();
|
||||
if fresh.vram_mb > 0 {
|
||||
c.vram_mb = fresh.vram_mb;
|
||||
}
|
||||
if !fresh.detail.is_empty() {
|
||||
c.detail = fresh.detail.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut placed: Vec<(usize, CardState)> = Vec::new();
|
||||
for (i, fresh) in diff.recovered.into_iter().chain(diff.revived.into_iter()) {
|
||||
placed.push((i, fresh));
|
||||
}
|
||||
let mut next = self.st().mining.cards.len();
|
||||
for fresh in diff.added.into_iter() {
|
||||
placed.push((next, fresh));
|
||||
next += 1;
|
||||
}
|
||||
for (i, mut fresh) in placed {
|
||||
let pref = crate::hotplug::pref_for(&prefs, &fresh).cloned();
|
||||
crate::hotplug::settle_new(&mut fresh, i, pref.as_ref(), now);
|
||||
let (kind, text) = crate::hotplug::added_words(&fresh);
|
||||
new_nvidia |= fresh.vendor == "nvidia" && fresh.enabled;
|
||||
let mut st = self.st();
|
||||
if i < st.mining.cards.len() {
|
||||
st.mining.cards[i] = fresh;
|
||||
} else {
|
||||
st.mining.cards.push(fresh);
|
||||
}
|
||||
drop(st);
|
||||
self.shared.event(kind, &text);
|
||||
}
|
||||
let line = crate::hotplug::cards_line(&self.st().mining.cards);
|
||||
self.shared.log(&line);
|
||||
self.last_cards_line = Instant::now();
|
||||
if self.running {
|
||||
self.plan_miners();
|
||||
if new_nvidia {
|
||||
self.apply_power_limits("new card");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops the worker on card `idx` (quit, then up to 8 s) and drops its slot; the sweep on it, if any, is aborted.
|
||||
fn drop_worker(&mut self, idx: usize, why: &str) {
|
||||
if self.sweep.as_ref().map(|r| r.card == idx).unwrap_or(false) {
|
||||
self.sweep_abort(why);
|
||||
}
|
||||
let Some(pos) = self.miners.iter().position(|m| m.card == idx) else { return };
|
||||
let label = self.miners[pos].label.clone();
|
||||
if let Some(mut p) = self.miners[pos].proc.take() {
|
||||
self.shared.log(&format!("stopping miner {label} ({why})"));
|
||||
p.write_stdin("quit\n");
|
||||
p.stop(8);
|
||||
}
|
||||
self.miners.remove(pos);
|
||||
self.stability.remove(&idx);
|
||||
}
|
||||
|
||||
fn start(&mut self) {
|
||||
if self.running {
|
||||
return;
|
||||
|
|
@ -1071,6 +1229,13 @@ impl Engine {
|
|||
let log = self.shared.runtime.log_dir.join(format!("node-{}{seg}.log", self.stamp));
|
||||
let args = self.node_args();
|
||||
let verifier = self.node_verifier();
|
||||
// the switches the Node page names: from the override file this start uses (none = the network's defaults)
|
||||
let switches = self.node_override_file().and_then(|p| std::fs::read_to_string(p).ok()).and_then(|t| serde_json::from_str::<Value>(&t).ok()).map(|v| switches_of(&v)).unwrap_or_default();
|
||||
{
|
||||
let mut st = self.st();
|
||||
st.node.consensus_switches = switches;
|
||||
st.node.consensus_digest = String::new();
|
||||
}
|
||||
match procs::spawn(Source::Node, &self.bins.node, &args, None, &log, &self.lines_tx, &verifier.env) {
|
||||
Ok(p) => {
|
||||
self.shared.log(&format!("igneumd started (pid {}): {}", p.pid(), p.cmdline));
|
||||
|
|
@ -1145,7 +1310,7 @@ impl Engine {
|
|||
let cards = self.st().mining.cards.clone();
|
||||
let id8 = self.shared.runtime.id8();
|
||||
for c in cards.iter() {
|
||||
if !c.enabled || self.miners.iter().any(|m| m.card == c.index) {
|
||||
if !c.enabled || !c.present() || self.miners.iter().any(|m| m.card == c.index) {
|
||||
continue;
|
||||
}
|
||||
let prefix = match c.vendor.as_str() {
|
||||
|
|
@ -1310,7 +1475,7 @@ impl Engine {
|
|||
let mut st = self.st();
|
||||
let paused = st.mining.paused;
|
||||
for c in st.mining.cards.iter_mut() {
|
||||
if c.enabled && c.state != "faulted" {
|
||||
if c.enabled && c.present() && c.state != "faulted" {
|
||||
c.state = if paused { "off".into() } else { "waiting".into() };
|
||||
c.hash_now = 0.0;
|
||||
c.pid = 0;
|
||||
|
|
@ -1351,6 +1516,9 @@ impl Engine {
|
|||
let mut st = self.st();
|
||||
for ch in &choices {
|
||||
let Some(c) = st.mining.cards.iter_mut().find(|c| c.key == ch.key) else { continue };
|
||||
if !c.present() {
|
||||
continue; // a removed or faulty card has no switch; its saved choice waits for it
|
||||
}
|
||||
let identities = ch.identities.clamp(1, 64);
|
||||
let enabled = ch.enabled && c.kind != "unknown";
|
||||
let power_pct = ch.power_pct.map(|p| p.clamp(crate::sweep::MIN_PCT, 100)).unwrap_or(c.power_pct);
|
||||
|
|
@ -1369,7 +1537,7 @@ impl Engine {
|
|||
if c.enabled != enabled || c.identities != identities {
|
||||
c.enabled = enabled;
|
||||
c.identities = identities;
|
||||
c.reason = String::new();
|
||||
c.reason = if !enabled && c.kind == "integrated" { crate::detect::INTEGRATED_REASON.into() } else { String::new() };
|
||||
if !enabled {
|
||||
c.state = "off".into();
|
||||
c.hash_now = 0.0;
|
||||
|
|
@ -1425,7 +1593,7 @@ impl Engine {
|
|||
let mut what = Vec::new();
|
||||
{
|
||||
let mut st = self.st();
|
||||
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0) {
|
||||
for c in st.mining.cards.iter_mut().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0) {
|
||||
let pct = if c.power_pct == 0 { 80 } else { c.power_pct.clamp(crate::sweep::MIN_PCT, 100) };
|
||||
c.power_pct = pct;
|
||||
let watts = requested_watts(c);
|
||||
|
|
@ -1445,7 +1613,7 @@ impl Engine {
|
|||
self.shared.log(&format!("power cap ({why}): {}", cmds.join(" & ")));
|
||||
let line = cmds.join(" & ");
|
||||
let what = what.join(", ");
|
||||
let want: std::collections::HashMap<String, f64> = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.power_default_w > 0.0).map(|c| (c.device.clone(), requested_watts(c))).collect();
|
||||
let want: std::collections::HashMap<String, f64> = self.st().mining.cards.iter().filter(|c| c.vendor == "nvidia" && c.enabled && c.present() && c.power_default_w > 0.0).map(|c| (c.device.clone(), requested_watts(c))).collect();
|
||||
if self.wrapper && cfg!(windows) {
|
||||
// the window host has a UI context: it shows the administrator prompt and reports back on stdin
|
||||
self.power_via_host = Some((line.clone(), what.clone(), want, Instant::now()));
|
||||
|
|
@ -1482,7 +1650,7 @@ impl Engine {
|
|||
.mining
|
||||
.cards
|
||||
.iter()
|
||||
.filter(|c| c.vendor == "nvidia" && c.power_applied && c.power_before_w > 0.0)
|
||||
.filter(|c| c.vendor == "nvidia" && c.present() && c.power_applied && c.power_before_w > 0.0)
|
||||
.map(|c| format!("\"{}\" -i {} -pl {}", crate::platform::tool("nvidia-smi").display(), c.device, c.power_before_w.round() as u64))
|
||||
.collect();
|
||||
drop(st);
|
||||
|
|
@ -2022,6 +2190,16 @@ impl Engine {
|
|||
self.last_settings_save = now;
|
||||
self.shared.save_settings();
|
||||
}
|
||||
// hot-plug (src/hotplug.rs): enumerate again every POLL_S, and keep the card list in the log for the console
|
||||
if self.detected && !self.detect_busy && !self.quitting && now >= self.detect_next {
|
||||
self.detect_next = now + Duration::from_secs(crate::hotplug::POLL_S);
|
||||
self.shared.send(Cmd::Detect);
|
||||
}
|
||||
if self.detected && now.duration_since(self.last_cards_line) >= Duration::from_secs(crate::hotplug::CARDS_LINE_S) {
|
||||
self.last_cards_line = now;
|
||||
let line = crate::hotplug::cards_line(&self.st().mining.cards);
|
||||
self.shared.log(&line);
|
||||
}
|
||||
}
|
||||
|
||||
/// The over-the-air updater (src/ota.rs): checks, downloads and stages on its own threads; this tick hands it what
|
||||
|
|
@ -2498,6 +2676,7 @@ 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;
|
||||
crate::hotplug::age(&mut st.mining.cards, unix);
|
||||
let cut = unix - 3600.0;
|
||||
st.mining.found.retain(|t| *t > cut);
|
||||
if st.node.daa > 0 {
|
||||
|
|
@ -2557,6 +2736,13 @@ 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) {
|
||||
if let Some(d) = digest_from_line(text) {
|
||||
let mut st = self.st();
|
||||
if st.node.consensus_digest != d {
|
||||
st.node.consensus_digest = d;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if !text.contains("too far into the future") {
|
||||
return;
|
||||
}
|
||||
|
|
@ -3172,7 +3358,27 @@ pub fn parse_race(body: &str) -> Option<RaceParsed> {
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_race, sync_decision, Reading};
|
||||
use super::{digest_from_line, parse_race, switches_of, sync_decision, Reading};
|
||||
|
||||
#[test]
|
||||
fn digest_comes_from_the_nodes_own_line_only() {
|
||||
let own = "2026-10-05 17:58:13.001+01:00 [INFO ] Consensus params digest: 1f4b44255fcd2ea8f75664ed47200f409186ddd2292960c9e2cf95bbbdc11505 (exchanged in the p2p handshake; a peer with another digest is refused)";
|
||||
assert_eq!(digest_from_line(own).as_deref(), Some("1f4b44255fcd2ea8f75664ed47200f409186ddd2292960c9e2cf95bbbdc11505"));
|
||||
let peer = "[WARN ] Refusing peer 188.245.5.161:26611: consensus params digest mismatch, local 72532d35 remote a6da35e8 (the peer's override file, environment or build differs)";
|
||||
assert_eq!(digest_from_line(peer), None);
|
||||
assert_eq!(digest_from_line("Consensus params digest: abc"), None);
|
||||
assert_eq!(digest_from_line("[INFO ] Processed 100 blocks"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switches_are_the_activation_heights_lowest_first_in_plain_words() {
|
||||
let v: serde_json::Value = serde_json::from_str(r#"{"difficulty_v2_activation_daa":33000,"fees_v1_activation_daa":210000,"finality_v3_activation_daa":135200,"proving_v0_activation_daa":84100,"max_block_mass":500000}"#).unwrap();
|
||||
let s = switches_of(&v);
|
||||
assert_eq!(s.iter().map(|x| (x.name.as_str(), x.daa)).collect::<Vec<_>>(), vec![("Difficulty v2", 33000), ("Proving v0", 84100), ("Finality v3", 135200), ("Fees v1", 210000)]);
|
||||
assert_eq!(s[3].key, "fees_v1_activation_daa");
|
||||
assert!(switches_of(&serde_json::json!({})).is_empty());
|
||||
assert!(switches_of(&serde_json::json!(null)).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn race_line_parses() {
|
||||
|
|
@ -3256,9 +3462,17 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
|||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
/// One pack export at a time (5 October 2026). prepare_worker runs on a thread per card, so two cards starting
|
||||
/// together ran two `igneum-miner export-pack` processes into the same folder; across an epoch change they
|
||||
/// interleaved and PC 1's packs\devnet was left with one epoch's program.h and the other's seeds.txt, which every
|
||||
/// OpenCL worker start then refused ("the epoch seed bytes do not give the pack's IGNEUM_SEEDW_INIT") until the next
|
||||
/// export. The second export of a pair rewrites the same pack, which is harmless.
|
||||
static EXPORT_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Exports this hour's program pack from the node to <app data>\packs\devnet (the prebuilt workers read it with --pack).
|
||||
#[allow(unused_variables)]
|
||||
fn export_pack(shared: &Arc<Shared>, bins: &Bins) -> Result<(), String> {
|
||||
let _one_at_a_time = EXPORT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let pack = shared.runtime.app_dir.join("packs").join("devnet");
|
||||
let _ = std::fs::create_dir_all(&pack);
|
||||
let out = crate::detect::run_timeout(std::process::Command::new(&bins.miner).args(["export-pack", &shared.runtime.rpc_url(), &pack.display().to_string()]), None, Duration::from_secs(120)).unwrap_or_default();
|
||||
|
|
@ -3277,6 +3491,7 @@ fn build_worker_from_source(shared: &Arc<Shared>, bins: &Bins, vendor: &str) ->
|
|||
#[cfg(windows)]
|
||||
{
|
||||
use std::process::Command;
|
||||
let _one_at_a_time = EXPORT_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let pack = shared.runtime.app_dir.join("packs").join("devnet");
|
||||
let _ = std::fs::create_dir_all(&pack);
|
||||
let out = crate::detect::run_timeout(Command::new(&bins.miner).args(["export-pack", &shared.runtime.rpc_url(), &pack.display().to_string()]), None, Duration::from_secs(120)).unwrap_or_default();
|
||||
|
|
|
|||
494
app/igneum-app/src/hotplug.rs
Normal file
|
|
@ -0,0 +1,494 @@
|
|||
//! Hot-plug: what changed between two enumerations of the cards (src/detect.rs runs one at start and one every
|
||||
//! minute; the Windows host also asks for one on WM_DEVICECHANGE). Pure, so the rules are unit-tested here; the
|
||||
//! engine applies the result (start a worker, stop one, mark a row). Born 5 October 2026, when an RX 9070 XT went
|
||||
//! into PC 1 through an eGPU box while the app ran and nothing noticed.
|
||||
//!
|
||||
//! Rules: a card is the same card when its key (vendor:code, "#2" for a twin) matches and the PCI addresses do not
|
||||
//! disagree, or, failing that, when vendor and name match and that pair is unique on both sides (a twin whose
|
||||
//! ordinal moved because the first one left). The device index is never part of the identity: it moves. A
|
||||
//! card missing from a list is removed only when the tool that lists its vendor answered. Removed and faulty cards
|
||||
//! stay in the engine's list (the other cards' indices are the miner slots), marked, and the dashboard hides a
|
||||
//! removed row after five minutes.
|
||||
|
||||
use crate::config::CardPref;
|
||||
use crate::state::CardState;
|
||||
|
||||
/// How long a removed card's row says "removed" before it hides.
|
||||
pub const REMOVED_SHOWN_S: f64 = 300.0;
|
||||
/// How often the engine enumerates again, seconds (macOS has no GPU hot-plug on Apple silicon: slower there).
|
||||
pub const POLL_S: u64 = if cfg!(target_os = "macos") { 300 } else { 60 };
|
||||
/// How often the app log carries the full card list, seconds (the console reads it from the log tail).
|
||||
pub const CARDS_LINE_S: u64 = 600;
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
pub struct Diff {
|
||||
/// new cards (usable or with a problem), to be appended
|
||||
pub added: Vec<CardState>,
|
||||
/// cards that were marked removed earlier and are listed again: (slot, the fresh entry)
|
||||
pub revived: Vec<(usize, CardState)>,
|
||||
/// slots whose card is gone
|
||||
pub removed: Vec<usize>,
|
||||
/// slots whose card now reports a problem: (slot, "Code 43")
|
||||
pub errored: Vec<(usize, String)>,
|
||||
/// slots whose card had a problem and is now driven by a tool again: (slot, the fresh entry)
|
||||
pub recovered: Vec<(usize, CardState)>,
|
||||
/// slots whose card is the same but its device index (or memory, bus) changed: (slot, the fresh entry)
|
||||
pub moved: Vec<(usize, CardState)>,
|
||||
pub unchanged: Vec<usize>,
|
||||
}
|
||||
|
||||
impl Diff {
|
||||
/// Nothing to do: every present card is where it was.
|
||||
pub fn is_quiet(&self) -> bool {
|
||||
self.added.is_empty() && self.revived.is_empty() && self.removed.is_empty() && self.errored.is_empty() && self.recovered.is_empty() && self.moved.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn bus_compat(a: &CardState, b: &CardState) -> bool {
|
||||
a.bus.is_empty() || b.bus.is_empty() || a.bus == b.bus
|
||||
}
|
||||
|
||||
fn same_identity(a: &CardState, b: &CardState) -> bool {
|
||||
a.vendor == b.vendor && (a.name.trim().eq_ignore_ascii_case(b.name.trim()) || (!a.code.is_empty() && a.code.eq_ignore_ascii_case(&b.code))) && bus_compat(a, b)
|
||||
}
|
||||
|
||||
/// Compares the engine's list with a fresh enumeration. `listed(card)` says whether this enumeration's tools could
|
||||
/// have seen that card (Detection::listed); a card its tool did not answer for is kept, not removed.
|
||||
pub fn diff(old: &[CardState], fresh: &[CardState], listed: &dyn Fn(&CardState) -> bool) -> Diff {
|
||||
let mut out = Diff::default();
|
||||
let mut used = vec![false; fresh.len()];
|
||||
let mut pair: Vec<Option<usize>> = vec![None; old.len()];
|
||||
// exact keys first
|
||||
for (i, o) in old.iter().enumerate() {
|
||||
if let Some(j) = fresh.iter().enumerate().position(|(j, f)| !used[j] && f.key == o.key && bus_compat(o, f)) {
|
||||
used[j] = true;
|
||||
pair[i] = Some(j);
|
||||
}
|
||||
}
|
||||
// then vendor + name, when that pair is unique among what is still unmatched on both sides
|
||||
for (i, o) in old.iter().enumerate() {
|
||||
if pair[i].is_some() {
|
||||
continue;
|
||||
}
|
||||
let cands: Vec<usize> = fresh.iter().enumerate().filter(|(j, f)| !used[*j] && same_identity(o, f)).map(|(j, _)| j).collect();
|
||||
let twins = old.iter().enumerate().filter(|(k, x)| pair[*k].is_none() && *k != i && same_identity(o, x)).count();
|
||||
if cands.len() == 1 && twins == 0 {
|
||||
used[cands[0]] = true;
|
||||
pair[i] = Some(cands[0]);
|
||||
}
|
||||
}
|
||||
for (i, o) in old.iter().enumerate() {
|
||||
match pair[i] {
|
||||
Some(j) => {
|
||||
let f = &fresh[j];
|
||||
if o.removed_at > 0.0 {
|
||||
out.revived.push((i, f.clone()));
|
||||
} else if o.problem.is_empty() && !f.problem.is_empty() {
|
||||
out.errored.push((i, f.problem.clone()));
|
||||
} else if !o.problem.is_empty() && f.problem.is_empty() {
|
||||
out.recovered.push((i, f.clone()));
|
||||
} else if !o.problem.is_empty() {
|
||||
// still faulty: the same problem or a new code, nothing to start or stop
|
||||
if o.problem != f.problem {
|
||||
out.errored.push((i, f.problem.clone()));
|
||||
} else {
|
||||
out.unchanged.push(i);
|
||||
}
|
||||
} else if o.device != f.device || o.key != f.key {
|
||||
out.moved.push((i, f.clone()));
|
||||
} else {
|
||||
out.unchanged.push(i);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
if o.removed_at > 0.0 {
|
||||
// already removed: stays hidden or shown as removed
|
||||
out.unchanged.push(i);
|
||||
} else if listed(o) {
|
||||
out.removed.push(i);
|
||||
} else {
|
||||
out.unchanged.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (j, f) in fresh.iter().enumerate() {
|
||||
if !used[j] {
|
||||
out.added.push(f.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The saved choice for a card: by its key (vendor:code), else a key saved by an app before 0.3.11 (vendor:index:code,
|
||||
/// vendor:index:name) when exactly one matches; an index that moved never changes the answer.
|
||||
pub fn pref_for<'a>(prefs: &'a std::collections::HashMap<String, CardPref>, c: &CardState) -> Option<&'a CardPref> {
|
||||
if let Some(p) = prefs.get(&c.key) {
|
||||
return Some(p);
|
||||
}
|
||||
if c.key.contains('#') {
|
||||
return None; // a twin's choice is its own
|
||||
}
|
||||
let head = format!("{}:", c.vendor);
|
||||
for tail in [format!(":{}", c.code), format!(":{}", c.name)] {
|
||||
if tail.len() <= 1 {
|
||||
continue;
|
||||
}
|
||||
let found: Vec<&CardPref> = prefs.iter().filter(|(k, _)| k.starts_with(&head) && k.ends_with(&tail) && !k.contains('#')).map(|(_, p)| p).collect();
|
||||
if found.len() == 1 {
|
||||
return Some(found[0]);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Applies a saved choice to a freshly detected card (the first detection and every later one use this).
|
||||
pub fn apply_pref(c: &mut CardState, p: &CardPref) {
|
||||
if !c.problem.is_empty() {
|
||||
return;
|
||||
}
|
||||
c.enabled = p.enabled && c.kind != "unknown";
|
||||
c.identities = p.identities.clamp(1, 64);
|
||||
if c.enabled || c.kind != "integrated" {
|
||||
c.reason = String::new();
|
||||
}
|
||||
if c.vendor == "nvidia" && p.power_pct > 0 {
|
||||
c.power_pct = p.power_pct.clamp(crate::sweep::MIN_PCT, 100);
|
||||
}
|
||||
c.pinned = p.pinned;
|
||||
c.sweep_pct = p.sweep_pct;
|
||||
c.sweep_eff = p.sweep_eff;
|
||||
c.sweep_watts = p.sweep_watts;
|
||||
c.sweep_mhs = p.sweep_mhs;
|
||||
c.sweep_at = p.sweep_at as f64;
|
||||
}
|
||||
|
||||
/// A card a re-detection added (or brought back): the saved choice if there is one, else the detect defaults it
|
||||
/// came with; its slot and the time it appeared.
|
||||
pub fn settle_new(c: &mut CardState, index: usize, pref: Option<&CardPref>, now: f64) {
|
||||
c.index = index;
|
||||
c.added_at = now;
|
||||
c.removed_at = 0.0;
|
||||
c.gone = false;
|
||||
if let Some(p) = pref {
|
||||
apply_pref(c, p);
|
||||
}
|
||||
if c.problem.is_empty() {
|
||||
c.state = if c.enabled { "waiting".into() } else { "off".into() };
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks a card unplugged: no worker, the row says removed, the saved choice is untouched.
|
||||
pub fn mark_removed(c: &mut CardState, now: f64) {
|
||||
c.removed_at = now;
|
||||
c.gone = false;
|
||||
c.state = "removed".into();
|
||||
c.message = "removed".into();
|
||||
c.hash_now = 0.0;
|
||||
c.pid = 0;
|
||||
c.restart_in_s = 0;
|
||||
c.ready = false;
|
||||
c.prepared = false;
|
||||
}
|
||||
|
||||
/// A removed row hides after REMOVED_SHOWN_S; returns true when something changed.
|
||||
pub fn age(cards: &mut [CardState], now: f64) -> bool {
|
||||
let mut changed = false;
|
||||
for c in cards.iter_mut() {
|
||||
let gone = c.removed_at > 0.0 && now - c.removed_at >= REMOVED_SHOWN_S;
|
||||
if gone != c.gone {
|
||||
c.gone = gone;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// The event line for a card that appeared: "New card: <name>, mining" and its kind (ok | info | warn).
|
||||
pub fn added_words(c: &CardState) -> (&'static str, String) {
|
||||
if !c.problem.is_empty() {
|
||||
("warn", format!("New card: {}, not usable ({}); {}", c.name, c.problem, crate::detect::PROBLEM_HINT))
|
||||
} else if c.enabled {
|
||||
("ok", format!("New card: {}, mining", c.name))
|
||||
} else if c.kind == "integrated" {
|
||||
("info", format!("New card: {}, off ({})", c.name, crate::detect::INTEGRATED_REASON))
|
||||
} else {
|
||||
("info", format!("New card: {}, off (switched off in settings)", c.name))
|
||||
}
|
||||
}
|
||||
|
||||
/// One word for a card's state on the log line and the console: mining, waiting, off, removed, not usable (Code 43).
|
||||
pub fn state_word(c: &CardState) -> String {
|
||||
if c.removed_at > 0.0 {
|
||||
"removed".into()
|
||||
} else if !c.problem.is_empty() {
|
||||
format!("not usable ({})", c.problem)
|
||||
} else if !c.enabled {
|
||||
"off".into()
|
||||
} else {
|
||||
c.state.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// The app-log line the console reads (relay/lib/parse.mjs): `cards: <name> [<kind>, <state>] | ...`, hidden rows
|
||||
/// left out, `cards: none` when nothing is listed.
|
||||
pub fn cards_line(cards: &[CardState]) -> String {
|
||||
let parts: Vec<String> = cards.iter().filter(|c| !c.gone).map(|c| format!("{} [{}, {}]", c.name.replace('|', "/").replace('[', "(").replace(']', ")"), c.kind, state_word(c))).collect();
|
||||
if parts.is_empty() { "cards: none".into() } else { format!("cards: {}", parts.join(" | ")) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::detect::{classify_kind, mark_unusable, INTEGRATED_REASON};
|
||||
|
||||
fn card(name: &str, vendor: &str, device: &str) -> CardState {
|
||||
let mut c = CardState { index: 0, key: format!("{vendor}:{name}"), code: name.into(), name: name.into(), vendor: vendor.into(), worker: if vendor == "nvidia" { "CUDA".into() } else { "OpenCL".into() }, device: device.into(), enabled: true, state: "off".into(), ..Default::default() };
|
||||
c.kind = classify_kind(name, None).into();
|
||||
crate::detect::apply_defaults(&mut c);
|
||||
c
|
||||
}
|
||||
// PC 1 at start on 5 October 2026: the 5090 on nvidia-smi index 0, the Ryzen iGPU as OpenCL device 0 (gfx1036)
|
||||
fn pc1_start() -> Vec<CardState> {
|
||||
let mut a = card("NVIDIA GeForce RTX 5090", "nvidia", "0");
|
||||
a.bus = "00000000:01:00.0".into();
|
||||
a.state = "mining".into();
|
||||
let mut b = card("gfx1036", "amd", "0");
|
||||
b.index = 1;
|
||||
vec![a, b]
|
||||
}
|
||||
fn all_listed(_: &CardState) -> bool {
|
||||
true
|
||||
}
|
||||
fn fresh_pc1_with_egpu() -> Vec<CardState> {
|
||||
let mut v = pc1_start();
|
||||
v[0].state = "off".into();
|
||||
let mut e = card("gfx1201", "amd", "1");
|
||||
e.index = 2;
|
||||
v.push(e);
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_list_is_quiet() {
|
||||
let old = pc1_start();
|
||||
let mut fresh = pc1_start();
|
||||
fresh[0].state = "off".into(); // runtime state in the fresh list means nothing
|
||||
let d = diff(&old, &fresh, &all_listed);
|
||||
assert!(d.is_quiet(), "{d:?}");
|
||||
assert_eq!(d.unchanged, vec![0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_usable_card_is_added_and_nothing_else_moves() {
|
||||
let old = pc1_start();
|
||||
let d = diff(&old, &fresh_pc1_with_egpu(), &all_listed);
|
||||
assert_eq!(d.added.len(), 1);
|
||||
assert_eq!(d.added[0].name, "gfx1201");
|
||||
assert_eq!(d.added[0].kind, "discrete");
|
||||
assert!(d.added[0].enabled);
|
||||
assert_eq!(d.unchanged, vec![0, 1]);
|
||||
assert!(d.removed.is_empty() && d.moved.is_empty() && d.errored.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_card_with_a_problem_is_added_as_unusable() {
|
||||
let old = pc1_start();
|
||||
let mut fresh = pc1_start();
|
||||
let mut bad = card("AMD Radeon RX 9070 XT", "amd", "");
|
||||
mark_unusable(&mut bad, "Code 43");
|
||||
fresh.push(bad);
|
||||
let d = diff(&old, &fresh, &all_listed);
|
||||
assert_eq!(d.added.len(), 1);
|
||||
assert_eq!(d.added[0].problem, "Code 43");
|
||||
assert!(!d.added[0].enabled);
|
||||
let (kind, text) = added_words(&d.added[0]);
|
||||
assert_eq!(kind, "warn");
|
||||
assert!(text.starts_with("New card: AMD Radeon RX 9070 XT, not usable (Code 43); reboot with the card attached"), "{text}");
|
||||
assert_eq!(state_word(&d.added[0]), "not usable (Code 43)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unplugged_card_is_removed_only_when_its_tool_answered() {
|
||||
let old = fresh_pc1_with_egpu();
|
||||
let fresh = pc1_start();
|
||||
let d = diff(&old, &fresh, &all_listed);
|
||||
assert_eq!(d.removed, vec![2]);
|
||||
assert_eq!(d.unchanged, vec![0, 1]);
|
||||
// the OpenCL worker did not answer this round: nothing is called removed
|
||||
let opencl_dead = |c: &CardState| c.vendor == "nvidia";
|
||||
let d2 = diff(&old, &pc1_start().into_iter().filter(|c| c.vendor == "nvidia").collect::<Vec<_>>(), &opencl_dead);
|
||||
assert!(d2.removed.is_empty(), "{d2:?}");
|
||||
assert!(d2.is_quiet());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_card_whose_index_moved_keeps_its_slot() {
|
||||
// the eGPU landed before the iGPU in the OpenCL list: the iGPU is device 1 now, the eGPU device 0
|
||||
let old = pc1_start();
|
||||
let mut fresh = pc1_start();
|
||||
fresh[1].device = "1".into();
|
||||
let mut e = card("gfx1201", "amd", "0");
|
||||
e.index = 2;
|
||||
fresh.push(e);
|
||||
let d = diff(&old, &fresh, &all_listed);
|
||||
assert_eq!(d.moved.len(), 1);
|
||||
assert_eq!(d.moved[0].0, 1);
|
||||
assert_eq!(d.moved[0].1.device, "1");
|
||||
assert_eq!(d.added.len(), 1);
|
||||
assert!(d.removed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_identical_cards_are_told_apart_by_key_and_never_swapped() {
|
||||
let mut a = card("NVIDIA GeForce RTX 5090", "nvidia", "0");
|
||||
let mut b = card("NVIDIA GeForce RTX 5090", "nvidia", "1");
|
||||
b.index = 1;
|
||||
b.key = "nvidia:NVIDIA GeForce RTX 5090#2".into();
|
||||
a.bus = "01:00.0".into();
|
||||
b.bus = "02:00.0".into();
|
||||
let old = vec![a.clone(), b.clone()];
|
||||
// the second twin leaves: the first keeps its slot by key; the missing one is removed, not "moved"
|
||||
let d = diff(&old, &[a.clone()], &all_listed);
|
||||
assert_eq!(d.unchanged, vec![0]);
|
||||
assert_eq!(d.removed, vec![1]);
|
||||
// the FIRST twin leaves: the survivor is now index 0 with the unsuffixed key, but its bus says which card it
|
||||
// is, so slot 1 is "moved" (new key and device) and slot 0 is removed; no worker is swapped between cards
|
||||
let mut survivor = b.clone();
|
||||
survivor.device = "0".into();
|
||||
survivor.key = "nvidia:NVIDIA GeForce RTX 5090".into();
|
||||
let d2 = diff(&old, &[survivor], &all_listed);
|
||||
assert_eq!(d2.removed, vec![0]);
|
||||
assert_eq!(d2.moved.len(), 1);
|
||||
assert_eq!(d2.moved[0].0, 1);
|
||||
assert_eq!(d2.moved[0].1.key, "nvidia:NVIDIA GeForce RTX 5090");
|
||||
// the same two cards again, nothing changed: quiet
|
||||
let d3 = diff(&old, &old, &all_listed);
|
||||
assert!(d3.is_quiet(), "{d3:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_driven_card_that_turns_faulty_is_errored_and_recovers_later() {
|
||||
let old = fresh_pc1_with_egpu();
|
||||
// the eGPU is still listed by Windows, now with Code 43, and no longer by OpenCL
|
||||
let mut fresh = pc1_start();
|
||||
let mut bad = card("gfx1201", "amd", "");
|
||||
mark_unusable(&mut bad, "Code 43");
|
||||
fresh.push(bad);
|
||||
let d = diff(&old, &fresh, &all_listed);
|
||||
assert_eq!(d.errored, vec![(2, "Code 43".to_string())]);
|
||||
assert!(d.added.is_empty() && d.removed.is_empty());
|
||||
// after a reboot with the card attached it is driven again: recovered, same slot
|
||||
let mut faulty = old.clone();
|
||||
mark_unusable(&mut faulty[2], "Code 43");
|
||||
faulty[2].device = String::new();
|
||||
let d2 = diff(&faulty, &fresh_pc1_with_egpu(), &all_listed);
|
||||
assert_eq!(d2.recovered.len(), 1);
|
||||
assert_eq!(d2.recovered[0].0, 2);
|
||||
assert!(d2.recovered[0].1.problem.is_empty());
|
||||
// the same problem again next minute: quiet
|
||||
let d3 = diff(&faulty, &fresh, &all_listed);
|
||||
assert!(d3.is_quiet(), "{d3:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_removed_card_that_comes_back_is_revived_in_its_slot() {
|
||||
let mut old = fresh_pc1_with_egpu();
|
||||
mark_removed(&mut old[2], 1000.0);
|
||||
assert_eq!(state_word(&old[2]), "removed");
|
||||
// still absent: quiet (and hidden after five minutes)
|
||||
let d = diff(&old, &pc1_start(), &all_listed);
|
||||
assert!(d.is_quiet(), "{d:?}");
|
||||
assert!(age(&mut old, 1000.0 + REMOVED_SHOWN_S));
|
||||
assert!(old[2].gone);
|
||||
assert!(!age(&mut old, 1000.0 + REMOVED_SHOWN_S + 1.0));
|
||||
// back: revived in slot 2
|
||||
let d2 = diff(&old, &fresh_pc1_with_egpu(), &all_listed);
|
||||
assert_eq!(d2.revived.len(), 1);
|
||||
assert_eq!(d2.revived[0].0, 2);
|
||||
assert!(d2.added.is_empty());
|
||||
let mut back = d2.revived[0].1.clone();
|
||||
settle_new(&mut back, 2, None, 2000.0);
|
||||
assert_eq!(back.index, 2);
|
||||
assert_eq!(back.removed_at, 0.0);
|
||||
assert!(!back.gone);
|
||||
assert_eq!(back.added_at, 2000.0);
|
||||
assert_eq!(back.state, "waiting");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_users_choice_is_kept_on_a_new_card_and_an_integrated_one_is_off_by_default() {
|
||||
let mut prefs = std::collections::HashMap::new();
|
||||
// the user switched the iGPU on earlier and set 2 identities; the setting was saved under OpenCL index 0
|
||||
prefs.insert("amd:0:gfx1036".to_string(), CardPref { enabled: true, identities: 2, ..Default::default() });
|
||||
// the iGPU comes back as device 1 (the eGPU took index 0): the 0.3.9 key still answers, by vendor and code
|
||||
let mut igpu = card("gfx1036", "amd", "1");
|
||||
assert_eq!(igpu.kind, "integrated");
|
||||
assert!(!igpu.enabled);
|
||||
assert_eq!(igpu.reason, INTEGRATED_REASON);
|
||||
let p = pref_for(&prefs, &igpu).cloned();
|
||||
assert!(p.is_some());
|
||||
settle_new(&mut igpu, 1, p.as_ref(), 5.0);
|
||||
assert!(igpu.enabled);
|
||||
assert_eq!(igpu.identities, 2);
|
||||
assert_eq!(igpu.reason, "");
|
||||
assert_eq!(igpu.state, "waiting");
|
||||
// the user switched it off (saved under the index-free key): the row keeps the integrated words
|
||||
prefs.insert("amd:gfx1036".to_string(), CardPref { enabled: false, identities: 1, ..Default::default() });
|
||||
let mut igpu2 = card("gfx1036", "amd", "1");
|
||||
let p2 = pref_for(&prefs, &igpu2).cloned();
|
||||
settle_new(&mut igpu2, 1, p2.as_ref(), 6.0);
|
||||
assert!(!igpu2.enabled);
|
||||
assert_eq!(igpu2.reason, INTEGRATED_REASON);
|
||||
assert_eq!(igpu2.state, "off");
|
||||
let (kind, text) = added_words(&igpu2);
|
||||
assert_eq!(kind, "info");
|
||||
assert_eq!(text, format!("New card: gfx1036, off ({INTEGRATED_REASON})"));
|
||||
// no saved choice: the detect default (integrated off, discrete on)
|
||||
let mut egpu = card("gfx1201", "amd", "0");
|
||||
settle_new(&mut egpu, 2, None, 7.0);
|
||||
assert!(egpu.enabled);
|
||||
assert_eq!(added_words(&egpu), ("ok", "New card: gfx1201, mining".to_string()));
|
||||
// a pref never switches on a card with a problem
|
||||
let mut bad = card("AMD Radeon RX 9070 XT", "amd", "");
|
||||
mark_unusable(&mut bad, "Code 43");
|
||||
settle_new(&mut bad, 3, Some(&CardPref { enabled: true, identities: 8, ..Default::default() }), 8.0);
|
||||
assert!(!bad.enabled);
|
||||
assert_eq!(bad.state, "unusable");
|
||||
// old keys only, two of them for the same code (the five-row PC 1 list had amd:0:gfx1036 and amd:2:gfx1036):
|
||||
// ambiguous, so the default applies; the index-free key, once saved, always wins
|
||||
prefs.remove("amd:gfx1036");
|
||||
prefs.insert("amd:2:gfx1036".to_string(), CardPref { enabled: true, identities: 3, ..Default::default() });
|
||||
let other = card("gfx1036", "amd", "7");
|
||||
assert!(pref_for(&prefs, &other).is_none());
|
||||
prefs.insert("amd:gfx1036".to_string(), CardPref { enabled: true, identities: 4, ..Default::default() });
|
||||
assert_eq!(pref_for(&prefs, &other).map(|p| p.identities), Some(4));
|
||||
// a twin never borrows the first card's choice
|
||||
let mut twin = card("gfx1201", "amd", "3");
|
||||
twin.key = "amd:gfx1201#2".into();
|
||||
prefs.insert("amd:gfx1201".to_string(), CardPref { enabled: false, identities: 1, ..Default::default() });
|
||||
assert!(pref_for(&prefs, &twin).is_none());
|
||||
// the name on the row is the Windows name while the key keeps the code: the 0.3.9 key by code still answers
|
||||
let mut named = card("gfx1201", "amd", "1");
|
||||
named.name = "AMD Radeon RX 9070 XT".into();
|
||||
prefs.clear();
|
||||
prefs.insert("amd:1:gfx1201".to_string(), CardPref { enabled: false, identities: 2, ..Default::default() });
|
||||
assert_eq!(pref_for(&prefs, &named).map(|p| p.identities), Some(2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_console_line_lists_every_shown_card_with_kind_and_state() {
|
||||
let mut cards = fresh_pc1_with_egpu();
|
||||
cards[0].state = "mining".into();
|
||||
cards[2].state = "starting".into();
|
||||
let mut bad = card("AMD Radeon RX 9070 XT", "amd", "");
|
||||
mark_unusable(&mut bad, "Code 43");
|
||||
cards.push(bad);
|
||||
assert_eq!(cards_line(&cards), "cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, starting] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]");
|
||||
mark_removed(&mut cards[2], 10.0);
|
||||
assert!(cards_line(&cards).contains("gfx1201 [discrete, removed]"));
|
||||
age(&mut cards, 10.0 + REMOVED_SHOWN_S);
|
||||
assert!(!cards_line(&cards).contains("gfx1201"));
|
||||
assert_eq!(cards_line(&[]), "cards: none");
|
||||
}
|
||||
}
|
||||
|
|
@ -1119,7 +1119,10 @@ fn run_script(shared: &Arc<Shared>, job: &Job, sink: &Sink, jobs_dir: &Path, dat
|
|||
std::fs::write(&wrapper, [b"\xEF\xBB\xBF".as_slice(), w.as_bytes()].concat()).map_err(|e| e.to_string())?;
|
||||
let _ = std::fs::remove_file(&out_file);
|
||||
let inner = format!("-NoProfile -ExecutionPolicy Bypass -File \"{}\"", wrapper.display());
|
||||
let ps = format!("$p = Start-Process -FilePath powershell.exe -ArgumentList '{}' -Verb RunAs -Wait -WindowStyle Hidden -PassThru; exit $p.ExitCode", inner.replace('\'', "''"));
|
||||
// A refused or unanswered UAC prompt makes Start-Process throw (`$p` stays null) and `exit $p.ExitCode`
|
||||
// would exit 0: the 5 October 2026 driver job on PC 1 was reported "done" after Windows cancelled its
|
||||
// prompt at 122 s. The launch failure is exit 251 and says so on stderr.
|
||||
let ps = format!("try {{ $p = Start-Process -FilePath powershell.exe -ArgumentList '{}' -Verb RunAs -Wait -WindowStyle Hidden -PassThru -ErrorAction Stop }} catch {{ Write-Error ('elevated launch failed (UAC refused, cancelled or timed out): ' + $_.Exception.Message); exit 251 }}; if ($null -eq $p) {{ Write-Error 'elevated launch failed: no process'; exit 251 }}; exit $p.ExitCode", inner.replace('\'', "''"));
|
||||
cmd = Command::new(crate::platform::tool("powershell"));
|
||||
cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]);
|
||||
} else if shell == "powershell" {
|
||||
|
|
@ -1145,6 +1148,7 @@ fn run_script(shared: &Arc<Shared>, job: &Job, sink: &Sink, jobs_dir: &Path, dat
|
|||
fn finish_ran(ran: Ran, what: &str) -> Result<Done, String> {
|
||||
match ran.code {
|
||||
Some(0) => Ok(Done { status: "done".into(), exit: 0, summary: format!("{what} finished, exit 0"), extra: json!({}) }),
|
||||
Some(251) => Ok(Done { status: "failed".into(), exit: 251, summary: format!("{what} did not start: the administrator prompt was refused, cancelled or timed out (click Yes within 2 minutes)"), extra: json!({}) }),
|
||||
Some(c) => Ok(Done { status: "failed".into(), exit: c as i64, summary: format!("{what} exited with code {c}"), extra: json!({}) }),
|
||||
None if ran.timed_out => Ok(Done { status: "timeout".into(), exit: -1, summary: format!("{what} hit the time cap and was ended"), extra: json!({}) }),
|
||||
None => Err(format!("{what} was ended")),
|
||||
|
|
@ -1259,6 +1263,20 @@ fn collect_done(uploaded: u32, failed: u32, names: Vec<String>, ran: Option<Ran>
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_refused_administrator_prompt_is_a_failure_not_done() {
|
||||
// 5 October 2026: the elevated launcher exited 0 after Windows cancelled an unanswered UAC prompt
|
||||
let d = finish_ran(Ran { code: Some(251), timed_out: false }, "script").unwrap();
|
||||
assert_eq!((d.status.as_str(), d.exit), ("failed", 251));
|
||||
assert!(d.summary.contains("administrator prompt"), "{}", d.summary);
|
||||
let d = finish_ran(Ran { code: Some(0), timed_out: false }, "script").unwrap();
|
||||
assert_eq!(d.status, "done");
|
||||
// the launcher string itself: a thrown Start-Process must not fall through to `exit $p.ExitCode`
|
||||
let src = include_str!("jobrun.rs");
|
||||
assert!(src.contains("-Verb RunAs -Wait -WindowStyle Hidden -PassThru -ErrorAction Stop }} catch {{"));
|
||||
assert!(src.contains("if ($null -eq $p) {{ Write-Error 'elevated launch failed: no process'; exit 251 }}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_outcome_follows_the_command_exit() {
|
||||
let d = collect_done(0, 0, vec![], None);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
mod config;
|
||||
mod detect;
|
||||
mod engine;
|
||||
mod hotplug;
|
||||
mod keys;
|
||||
mod platform;
|
||||
mod procs;
|
||||
|
|
@ -135,6 +136,8 @@ fn main() {
|
|||
"quit" => shared.send(engine::Cmd::Quit),
|
||||
"pause" => shared.send(engine::Cmd::Pause),
|
||||
"resume" => shared.send(engine::Cmd::Resume),
|
||||
// the window host saw WM_DEVICECHANGE (a card plugged in or out): enumerate now, not at the next minute
|
||||
"detect" => shared.send(engine::Cmd::Detect),
|
||||
"elevated ok" => shared.send(engine::Cmd::ElevatedDone(Ok(()))),
|
||||
_ if t.starts_with("elevated fail") => shared.send(engine::Cmd::ElevatedDone(Err(t.trim_start_matches("elevated fail").trim_start_matches(':').trim().to_string()))),
|
||||
_ => {}
|
||||
|
|
|
|||
|
|
@ -288,6 +288,44 @@ fn set<F: FnOnce(&mut crate::state::ProvingState)>(shared: &Shared, f: F) {
|
|||
f(&mut st.proving);
|
||||
}
|
||||
|
||||
/// The pinned ids out of `igneum-prove-host --mode id` ("RESULT id: pinned guests: shard program id 0x... (...)
|
||||
/// aggregator id 0x... (...)"): (shard program id, aggregator id). None when the line is not there.
|
||||
pub fn ids_from_describe(text: &str) -> Option<(String, String)> {
|
||||
let line = text.lines().find(|l| l.contains("shard program id "))?;
|
||||
let after = |key: &str| -> Option<String> {
|
||||
let rest = line.split(key).nth(1)?.trim_start();
|
||||
let id: String = rest.chars().take_while(|c| c.is_ascii_alphanumeric()).collect();
|
||||
(id.starts_with("0x") && id.len() == 66).then_some(id)
|
||||
};
|
||||
Some((after("shard program id ")?, after("aggregator id ")?))
|
||||
}
|
||||
|
||||
/// Reads the pinned ids once (`--mode id` does no key setup, under a second) and puts them on the state. The Prove
|
||||
/// page shows them with the verifier state, proving on or off.
|
||||
fn read_ids(shared: &Shared, t: &Tools) {
|
||||
if !shared.state.lock().unwrap().proving.program_id.is_empty() {
|
||||
return;
|
||||
}
|
||||
// not run_tool: that stops the child while proving is off, and the ids are wanted proving on or off
|
||||
let out = if t.wsl {
|
||||
let body = format!("export RUST_LOG=off\nexec {} --mode id", crate::wslhost::sq(&t.host.display().to_string()));
|
||||
let Ok(file) = crate::wslhost::write_script("prove-ids", &body) else { return };
|
||||
crate::detect::run_timeout(&mut crate::wslhost::command(&crate::platform::tool("wsl"), crate::wslhost::DISTRO, None, &file.path, true, &[]), None, Duration::from_secs(60))
|
||||
} else {
|
||||
crate::detect::run_timeout(Command::new(&t.host).args(["--mode", "id"]).env("RUST_LOG", "off"), None, Duration::from_secs(60))
|
||||
};
|
||||
match ids_from_describe(&out.unwrap_or_default()) {
|
||||
Some((shard, agg)) => {
|
||||
shared.log(&format!("prover: pinned shard program id {shard}, aggregator id {agg}"));
|
||||
set(shared, |p| {
|
||||
p.program_id = shard;
|
||||
p.aggregator_id = agg;
|
||||
});
|
||||
}
|
||||
None => shared.log("prover: --mode id printed no pinned ids (an older host)"),
|
||||
}
|
||||
}
|
||||
|
||||
static BIN_DIR: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// Starts the prover thread. It idles while the setting is off or the node is not synced.
|
||||
|
|
@ -306,6 +344,13 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
let mut submitted: Vec<(u64, String, u32, u128)> = Vec::new();
|
||||
let mut last_verifier_read = Instant::now() - Duration::from_secs(600);
|
||||
let mut asked_restart = false;
|
||||
// macOS and Linux: the host sits next to the engine, so its pinned ids are read at once, proving on or off
|
||||
// (Windows runs the host inside WSL2, which is probed only once proving is on)
|
||||
if !cfg!(windows) {
|
||||
if let Ok(t) = find_tools(&bin_dir) {
|
||||
read_ids(&shared, &t);
|
||||
}
|
||||
}
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(10));
|
||||
let enabled = shared.settings.lock().unwrap().prove;
|
||||
|
|
@ -345,6 +390,7 @@ fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
|||
asked_restart = true;
|
||||
shared.send(crate::engine::Cmd::RestartNode("the WSL2 prover is installed now; the node restarts to verify proof records".into()));
|
||||
}
|
||||
read_ids(&shared, &t);
|
||||
tools = Some(t);
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -544,6 +590,16 @@ pub fn setup(shared: &Shared) -> Result<Value, String> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pinned_ids_come_out_of_the_hosts_id_line() {
|
||||
let out = "RESULT id: pinned guests: shard program id 0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a (2832504 bytes, sha256 0x150f4c05a2951fc5) aggregator id 0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896 (319744 bytes), pinned 2026-10-05T16:20:38Z on Darwin, SP1 5.0 circuit v5\n";
|
||||
let (shard, agg) = ids_from_describe(out).unwrap();
|
||||
assert_eq!(shard, "0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a");
|
||||
assert_eq!(agg, "0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896");
|
||||
assert_eq!(ids_from_describe("RESULT setup: 1.2 s"), None);
|
||||
assert_eq!(ids_from_describe("shard program id 0xabc aggregator id 0xdef"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chooses_the_newest_unpaid_assigned_shard_not_yet_attempted() {
|
||||
let v: Value = serde_json::from_str(r#"[
|
||||
|
|
|
|||
|
|
@ -24,12 +24,26 @@ pub struct NodeState {
|
|||
/// a consensus switch the signed manifest announced (difficulty v2 activation DAA); 0 = none
|
||||
pub consensus_switch_daa: u64,
|
||||
pub override_restart_wait: String,
|
||||
/// the node's "Consensus params digest: <64 hex>" line (exchanged in the p2p handshake); empty until the node prints it
|
||||
pub consensus_digest: String,
|
||||
/// every activation height in the override file the node was started with (miner-ui-2): the Node page names the next one
|
||||
pub consensus_switches: Vec<ConsensusSwitch>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
/// One planned rule change the node applies by itself at a DAA score (from the override file's `*_activation_daa` keys).
|
||||
#[derive(Clone, Serialize, Default, PartialEq, Debug)]
|
||||
pub struct ConsensusSwitch {
|
||||
pub key: String, // the override key, for example fees_v1_activation_daa
|
||||
pub name: String, // plain words: Fees v1
|
||||
pub daa: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default, Debug)]
|
||||
pub struct CardState {
|
||||
pub index: usize,
|
||||
pub key: String, // stable id for the saved preference: vendor:device:name
|
||||
pub key: String, // stable id for the saved preference: vendor:code, "#2" and up for a second identical card (no index: it moves)
|
||||
pub code: String, // the tool's own device name (AMD's OpenCL runtime says "gfx1201"); the key and the tooltip carry it
|
||||
pub platform: String, // the OpenCL platform and driver the worker opens it through (the tooltip)
|
||||
pub kind: String, // apple | discrete | integrated | external | unknown
|
||||
pub vram_mb: u64, // 0 when unknown
|
||||
pub reason: String, // why it is off by default, if it is
|
||||
|
|
@ -40,7 +54,7 @@ pub struct CardState {
|
|||
pub detail: String, // memory, cores
|
||||
pub device: String, // the worker's --device value (Windows)
|
||||
pub enabled: bool,
|
||||
pub state: String, // off | waiting | starting | ready | mining | restarting | failed | faulted (the watchdog gave up on it)
|
||||
pub state: String, // off | waiting | starting | ready | mining | restarting | failed | faulted (the watchdog gave up on it) | unusable (the OS reports a problem) | removed (unplugged)
|
||||
pub hash_now: f64, // MH/s, the last interval
|
||||
pub hash_avg: f64, // MH/s since the start
|
||||
pub accepted: u64,
|
||||
|
|
@ -57,6 +71,15 @@ pub struct CardState {
|
|||
pub pid: u32,
|
||||
pub last_status_age_s: f64,
|
||||
pub message: String,
|
||||
/// the device's own problem as the OS reports it ("Code 43" on Windows); set = listed but no worker can drive it
|
||||
pub problem: String,
|
||||
/// PCI bus id where the tool gives one (nvidia-smi pci.bus_id); a second way to recognise a card whose index moved
|
||||
pub bus: String,
|
||||
/// hot-plug (src/hotplug.rs): unix s when a re-detection added this card (0 = found at start), when it lost it
|
||||
/// (0 = present), and `gone` once a removed card has been shown as removed for five minutes (the row hides)
|
||||
pub added_at: f64,
|
||||
pub removed_at: f64,
|
||||
pub gone: bool,
|
||||
/// `WORKER FAULT` lines seen (the miner killed and restarted its worker) and the miner's own `faults=` count
|
||||
pub faults: u64,
|
||||
/// shares that failed the miner's CPU re-check this run (`mismatched=` on the STATUS line)
|
||||
|
|
@ -92,6 +115,14 @@ pub struct CardState {
|
|||
pub race_variants: u32,
|
||||
}
|
||||
|
||||
impl CardState {
|
||||
/// Listed, driver fine, not unplugged: a worker can run on it (hot-plug keeps removed and faulty cards in the
|
||||
/// list so the other cards' indices stay put).
|
||||
pub fn present(&self) -> bool {
|
||||
self.removed_at == 0.0 && self.problem.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
pub struct MiningState {
|
||||
pub state: String, // idle | waiting | mining | paused | stopped
|
||||
|
|
@ -162,6 +193,9 @@ pub struct ProvingState {
|
|||
pub pool_entries: u64,
|
||||
pub pool_verified: u64,
|
||||
pub pool_failed: u64,
|
||||
/// the pinned guests' ids (`igneum-prove-host --mode id`): the shard program and the aggregator; empty until read
|
||||
pub program_id: String,
|
||||
pub aggregator_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
/* Igneum Miner dashboard. The site's tokens (site/index.html): obsidian, graphite, ember, molten, bone, ash;
|
||||
Unbounded for headings, IBM Plex Sans for text, IBM Plex Mono for numbers and labels. Fonts ship in the binary.
|
||||
One type scale (--t-*), one spacing scale (--s-*), one colour set (:root), used by every screen. */
|
||||
/* Igneum Miner dashboard (miner-ui-2). The site's tokens (site/index.html): obsidian, graphite, ember, molten, bone,
|
||||
ash; Unbounded for headings, IBM Plex Sans for text, IBM Plex Mono for numbers and labels. Fonts ship in the binary.
|
||||
One type scale (--t-*), one spacing scale (--s-*), one colour set (:root), one accent (ember), used by every page.
|
||||
Layout: a rail on the left (six sections), a thin top bar, the status strip under it, the page in the middle, the
|
||||
log drawer at the bottom. The window lays out from 900 x 600 up (the hosts say the same minimum).
|
||||
Nothing here is newer than 2022 CSS (the WebView2 host). */
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/IBMPlexMono-400.woff2) format('woff2')}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(fonts/IBMPlexMono-500.woff2) format('woff2')}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(fonts/IBMPlexSans-400.woff2) format('woff2')}
|
||||
|
|
@ -13,14 +16,14 @@
|
|||
:root{
|
||||
/* colour */
|
||||
--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--ember-hi:#FF6A2B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;
|
||||
--ember-12:rgba(242,84,27,.12);--ember-40:rgba(242,84,27,.4);--molten-10:rgba(255,179,92,.1);--molten-40:rgba(255,179,92,.4);--node-blue:#7FA7C9;--nvidia:#8BE37A;
|
||||
--ember-12:rgba(242,84,27,.12);--ember-40:rgba(242,84,27,.4);--molten-10:rgba(255,179,92,.1);--molten-40:rgba(255,179,92,.4);--node-blue:#7FA7C9;--nvidia:#8BE37A;--rail-bg:#111114;
|
||||
/* type scale */
|
||||
--t-xs:11px;--t-sm:12px;--t-base:13px;--t-md:14px;--t-lg:15px;--t-xl:16px;--t-2xl:18px;--t-num:26px;--t-h3:16px;--t-h2:32px;--t-h1:52px;
|
||||
/* spacing scale */
|
||||
--s-1:4px;--s-2:8px;--s-3:12px;--s-4:16px;--s-5:20px;--s-6:28px;--s-7:40px;
|
||||
--gutter:var(--s-6);--card-pad:22px;--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:var(--s-5);--gap-tile:var(--s-3);
|
||||
--sans:'IBM Plex Sans',system-ui,-apple-system,sans-serif;--mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--head:'Unbounded',sans-serif;
|
||||
--top:60px;--bottom:52px;--drawer-h:260px}
|
||||
--top:60px;--bottom:0px;--drawer-h:260px;--rail:196px}
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--sans);font-size:var(--t-lg);line-height:1.5;-webkit-font-smoothing:antialiased;overflow:hidden;user-select:none;-webkit-user-select:none;font-variant-numeric:tabular-nums}
|
||||
|
|
@ -53,21 +56,48 @@ input,textarea{font-variant-numeric:tabular-nums}
|
|||
.btn.ghost:hover{border-color:var(--line-2);color:var(--bone)}
|
||||
.btn.ghost.on{color:var(--molten);border-color:var(--molten-40);background:var(--molten-10)}
|
||||
.btn.danger:hover{color:var(--ember);border-color:var(--ember)}
|
||||
.btn .chev{transition:transform .2s ease;color:var(--ash)}
|
||||
.btn.on .chev{transform:rotate(180deg);color:var(--molten)}
|
||||
.icon-btn{width:38px;height:38px;border-radius:10px;border:1px solid var(--line-2);background:transparent;display:inline-flex;align-items:center;justify-content:center;cursor:pointer;color:var(--ink-2);font-size:20px;line-height:1}
|
||||
.icon-btn:hover{color:var(--bone);border-color:var(--ash)}
|
||||
:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
|
||||
@media (prefers-reduced-motion:reduce){.btn,.btn .chev{transition:none}}
|
||||
@media (prefers-reduced-motion:reduce){.btn{transition:none}}
|
||||
|
||||
/* the rail */
|
||||
.rail{position:fixed;top:0;left:0;bottom:0;width:var(--rail);background:var(--rail-bg);border-right:1px solid var(--line);display:flex;flex-direction:column;z-index:22;padding:14px 12px 14px;-webkit-app-region:drag}
|
||||
.rail button{-webkit-app-region:no-drag}
|
||||
.rail-brand{display:flex;align-items:center;gap:10px;padding:6px 10px 18px;min-width:0}
|
||||
body.mac .rail-brand{padding-top:30px}
|
||||
.rail-brand .word{font-family:var(--head);font-weight:900;font-size:17px;letter-spacing:.06em}
|
||||
.rail-nav{display:flex;flex-direction:column;gap:3px}
|
||||
.nav{position:relative;display:flex;align-items:center;gap:12px;width:100%;min-height:42px;padding:8px 12px;border:1px solid transparent;border-radius:11px;background:transparent;color:var(--ink-2);font-weight:500;font-size:var(--t-md);text-align:left;cursor:pointer;transition:background .15s ease,color .15s ease,border-color .15s ease}
|
||||
.nav svg{width:19px;height:19px;flex:0 0 19px;fill:none;stroke:currentColor;stroke-width:1.9;stroke-linecap:round;stroke-linejoin:round;color:var(--ash);transition:color .15s ease}
|
||||
.nav:hover{background:rgba(255,255,255,.04);color:var(--bone)}
|
||||
.nav:hover svg{color:var(--ink-2)}
|
||||
.nav.on{background:var(--ember-12);border-color:rgba(242,84,27,.28);color:var(--bone);font-weight:600}
|
||||
.nav.on svg{color:var(--ember)}
|
||||
.nav.on::before{content:"";position:absolute;left:-13px;top:10px;bottom:10px;width:3px;border-radius:0 3px 3px 0;background:var(--ember)}
|
||||
.nav.small{min-height:36px;font-size:var(--t-base);color:var(--ash)}
|
||||
.nav.small svg{width:16px;height:16px;flex-basis:16px}
|
||||
.nav.danger:hover{color:var(--ember)}
|
||||
.nav.danger:hover svg{color:var(--ember)}
|
||||
.nav.on.logs{color:var(--molten)}
|
||||
.nav-dot{position:absolute;right:12px;top:50%;width:7px;height:7px;margin-top:-3px;border-radius:50%;background:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)}
|
||||
.rail-foot{margin-top:auto;display:flex;flex-direction:column;gap:2px;padding-top:12px;border-top:1px solid var(--line)}
|
||||
.rail-status{font-size:var(--t-xs);color:var(--ash);padding:0 12px 10px;line-height:1.55;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.rail-status b{color:var(--ink-2);font-weight:500}
|
||||
.rail-version{font-size:var(--t-xs);color:var(--ash);padding:8px 12px 0;letter-spacing:.06em}
|
||||
|
||||
/* top bar */
|
||||
.top{position:fixed;top:0;left:0;right:0;height:var(--top);display:flex;align-items:center;justify-content:space-between;gap:var(--s-4);padding:0 var(--gutter);background:rgba(12,12,14,.86);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border-bottom:1px solid rgba(42,42,48,.7);z-index:20;-webkit-app-region:drag}
|
||||
.top button,.top .pill{-webkit-app-region:no-drag}
|
||||
body.mac .top{padding-left:92px}
|
||||
body.mac:not(.has-rail) .top{padding-left:92px}
|
||||
body.has-rail .top{left:var(--rail)}
|
||||
.brand{display:flex;align-items:center;gap:10px;min-width:0}
|
||||
.brand .word{font-family:var(--head);font-weight:900;font-size:20px;letter-spacing:.06em}
|
||||
.brand .miner{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.22em;color:var(--ash);margin-left:var(--s-1);padding-top:3px}
|
||||
.top-right{display:flex;align-items:center;gap:var(--s-3);flex:0 0 auto}
|
||||
.page-title{display:flex;align-items:baseline;gap:12px;min-width:0}
|
||||
.page-title h1{font-size:19px;font-weight:700;letter-spacing:0;white-space:nowrap}
|
||||
.page-sub{font-size:var(--t-base);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
||||
.top-right{display:flex;align-items:center;gap:var(--s-3);flex:0 0 auto;min-width:0}
|
||||
.top-status{font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:380px}
|
||||
.top-status .pc+.pc::before{content:"·";margin:0 7px;color:var(--line-2)}
|
||||
.pill{display:inline-flex;align-items:center;gap:var(--s-2);font-family:var(--mono);font-size:var(--t-sm);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line);border-radius:999px;padding:6px 12px 6px 10px;background:var(--graphite);white-space:nowrap;font-variant-numeric:tabular-nums;min-width:112px;justify-content:center}
|
||||
.pill.on{color:var(--molten);border-color:var(--molten-40)}
|
||||
.pill.warn{color:var(--ember)}
|
||||
|
|
@ -78,11 +108,12 @@ body.mac .top{padding-left:92px}
|
|||
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 7px rgba(255,179,92,0)}}
|
||||
@media (prefers-reduced-motion:reduce){.on .dot,.dot.live{animation:none}}
|
||||
|
||||
/* the notice strip under the top bar (app.js, Notices): one notice at a time. It takes no room while empty; main's
|
||||
/* the status strip under the top bar (app.js, Notices): one notice at a time. It takes no room while empty; main's
|
||||
top moves once per change with a 150 ms transition (layoutStrip), never per poll. Tones: default molten (running,
|
||||
available, done), bad ember (failed, clock block, urgent). Nothing here is newer than 2022 CSS (WebView2 host). */
|
||||
available, done), bad ember (failed, clock block, urgent). */
|
||||
.notices{position:fixed;top:var(--top);left:0;right:0;z-index:19}
|
||||
.notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-4);padding:9px calc(var(--gutter) - 6px) 9px var(--gutter);background:var(--molten-10);border-bottom:1px solid var(--molten-40);font-size:var(--t-md);line-height:1.4;color:var(--bone)}
|
||||
body.has-rail .notices{left:var(--rail)}
|
||||
.notice{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s-2) var(--s-4);padding:8px calc(var(--gutter) - 6px) 8px var(--gutter);background:var(--molten-10);border-bottom:1px solid var(--molten-40);font-size:var(--t-base);line-height:1.4;color:var(--bone)}
|
||||
.notice.bad{background:var(--ember-12);border-bottom-color:var(--ember-40)}
|
||||
.notice.warn{background:var(--molten-10);border-bottom-color:var(--molten-40)}
|
||||
.notice.update-urgent{background:rgba(242,84,27,.55);border-bottom-color:var(--ember);color:#fff;font-weight:600}
|
||||
|
|
@ -95,25 +126,18 @@ body.mac .top{padding-left:92px}
|
|||
.notice-detail{flex-basis:100%;font-size:var(--t-sm);color:var(--ink-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-top:-3px}
|
||||
.notice .prog{flex-basis:100%;height:3px;background:rgba(255,255,255,.12);border-radius:2px;overflow:hidden;margin-top:-3px}
|
||||
.notice .prog i{display:block;height:100%;width:0;background:var(--ember);transition:width .5s linear}
|
||||
.job-history table{margin-top:var(--s-2)}
|
||||
.job-history th{text-align:left;font-weight:500;color:var(--ash);font-size:var(--t-xs);padding:4px 6px 4px 0}
|
||||
.job-history td{padding:5px 6px 5px 0;border-top:1px solid var(--line);vertical-align:top}
|
||||
.job-history tr.failed td,.job-history tr.timeout td,.job-history tr.aborted td{color:var(--ember)}
|
||||
.job-history tr.running td{color:var(--molten)}
|
||||
.clock-card{margin-top:var(--s-3);border:1px solid rgba(242,84,27,.5);background:rgba(242,84,27,.08);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:var(--s-2)}
|
||||
.clock-card.warn{border-color:var(--molten-40);background:rgba(255,179,92,.06)}
|
||||
.clock-msg{font-size:var(--t-md);color:var(--bone);line-height:1.45}
|
||||
.clock-card .note{margin-top:0}
|
||||
|
||||
/* screens */
|
||||
/* screens and pages */
|
||||
main{position:absolute;top:var(--top);bottom:0;left:0;right:0;overflow:auto;padding:0 var(--gutter);overscroll-behavior:contain;transition:top .15s ease}
|
||||
@media (prefers-reduced-motion:reduce){main{transition:none}}
|
||||
body.has-bottom main{bottom:var(--bottom)}
|
||||
body.drawer-open main{bottom:calc(var(--bottom) + var(--drawer-h))}
|
||||
body.has-rail main{left:var(--rail)}
|
||||
body.drawer-open main{bottom:var(--drawer-h)}
|
||||
.screen{display:none;max-width:1080px;margin:0 auto;animation:rise .45s ease}
|
||||
body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-cards,body[data-phase="address"] #screen-address,body[data-phase="dashboard"] #screen-dashboard{display:block}
|
||||
@keyframes rise{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
|
||||
@media (prefers-reduced-motion:reduce){.screen{animation:none}}
|
||||
@media (prefers-reduced-motion:reduce){.screen,.page{animation:none}}
|
||||
#screen-dashboard{padding:22px 0 32px}
|
||||
.page{display:flex;flex-direction:column;gap:var(--gap);animation:rise .3s ease}
|
||||
|
||||
/* welcome */
|
||||
.hero{min-height:calc(100vh - var(--top));display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:var(--s-4);padding:var(--s-7) 0 48px}
|
||||
|
|
@ -128,7 +152,6 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
|
|||
.tile .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.14em;color:var(--ember)}
|
||||
.tile .t{font-family:var(--head);font-weight:700;font-size:var(--t-lg);line-height:1.25}
|
||||
.tile .s{font-size:var(--t-base);color:var(--ash);line-height:1.45}
|
||||
.tile .h{font-family:var(--head);font-weight:700;font-size:var(--t-h3);margin-bottom:var(--s-2)}
|
||||
.cta{display:flex;flex-wrap:wrap;gap:var(--s-3);align-items:center;justify-content:center;margin-top:10px}
|
||||
.seedline{font-size:var(--t-sm);color:var(--ash);letter-spacing:.04em}
|
||||
|
||||
|
|
@ -138,6 +161,7 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
|
|||
.step .cta{justify-content:flex-start;margin-top:var(--s-2)}
|
||||
.note{font-size:var(--t-base);color:var(--ash);line-height:1.5}
|
||||
.note.small{font-size:var(--t-sm);word-break:break-all}
|
||||
.help{font-size:var(--t-base);color:var(--ash);line-height:1.5;max-width:64ch}
|
||||
.cards{display:flex;flex-direction:column;gap:var(--s-3);margin-top:var(--s-2)}
|
||||
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0}
|
||||
.card.detecting{display:flex;align-items:center;gap:18px}
|
||||
|
|
@ -145,27 +169,14 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
|
|||
.card.detecting .s{font-size:var(--t-sm);color:var(--ash);margin-top:var(--s-1)}
|
||||
.spinner{width:28px;height:28px;border-radius:50%;border:3px solid var(--line-2);border-top-color:var(--ember);animation:spin 1s linear infinite;flex:0 0 28px}
|
||||
@keyframes spin{to{transform:rotate(360deg)}}
|
||||
.gpu{display:flex;align-items:center;gap:18px}
|
||||
.gpu .badge{width:52px;height:52px;border-radius:14px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;flex:0 0 52px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)}
|
||||
.gpu .badge.apple{color:var(--bone)}
|
||||
.gpu .badge.nvidia{color:var(--nvidia)}
|
||||
.gpu .badge.amd{color:var(--ember)}
|
||||
.gpu .name{font-family:var(--head);font-weight:700;font-size:var(--t-2xl);line-height:1.2}
|
||||
.gpu .meta{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);margin-top:5px;display:flex;flex-wrap:wrap;gap:6px 14px}
|
||||
.gpu .meta b{color:var(--ink-2);font-weight:500}
|
||||
.gpu .tick{margin-left:auto;width:36px;height:36px;border-radius:50%;background:var(--ember);display:flex;align-items:center;justify-content:center;flex:0 0 36px}
|
||||
.gpu.off .tick{background:var(--line-2)}
|
||||
.gpu .tick svg path{stroke-dasharray:30;stroke-dashoffset:30;animation:draw .8s .2s ease forwards}
|
||||
@keyframes draw{to{stroke-dashoffset:0}}
|
||||
.gpu .msg{font-size:var(--t-sm);color:var(--ember);margin-top:var(--s-1)}
|
||||
|
||||
/* GPU rows (first run and settings) */
|
||||
/* GPU rows (first run) */
|
||||
.gpu-row{display:flex;align-items:center;gap:var(--s-4);background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:16px 20px;min-width:0;flex-wrap:wrap}
|
||||
.gpu-row.off{opacity:.72}
|
||||
.gpu-row .badge{width:48px;height:48px;border-radius:13px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;flex:0 0 48px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)}
|
||||
.gpu-row .badge.apple{color:var(--bone)}
|
||||
.gpu-row .badge.nvidia{color:var(--nvidia)}
|
||||
.gpu-row .badge.amd{color:var(--ember)}
|
||||
.badge{width:48px;height:48px;border-radius:13px;display:flex;align-items:center;justify-content:center;font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;flex:0 0 48px;border:1px solid var(--line-2);color:var(--molten);background:var(--obsidian)}
|
||||
.badge.apple{color:var(--bone)}
|
||||
.badge.nvidia{color:var(--nvidia)}
|
||||
.badge.amd{color:var(--ember)}
|
||||
.gpu-row .info{flex:1;min-width:180px;display:flex;flex-direction:column;gap:var(--s-1)}
|
||||
.gpu-row .name{font-family:var(--head);font-weight:700;font-size:var(--t-xl);line-height:1.2;display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.kind{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;border-radius:999px;padding:2px 8px;border:1px solid var(--line-2);color:var(--ash);font-weight:500;white-space:nowrap}
|
||||
|
|
@ -175,56 +186,147 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
|
|||
.gpu-row .meta b{color:var(--ink-2);font-weight:500}
|
||||
.gpu-row .reason{font-size:var(--t-sm);color:var(--ash)}
|
||||
.gpu-row .msg{font-size:var(--t-sm);color:var(--ember)}
|
||||
.ids{display:flex;align-items:center;gap:6px;flex:0 0 auto}
|
||||
.ids .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);margin-right:var(--s-1)}
|
||||
.gpu-row .switch{padding:0;flex:0 0 auto;margin-left:auto}
|
||||
/* hot-plug (src/hotplug.rs): a removed card dims, a faulty one is named in ember, neither has live controls */
|
||||
.gpu-row.removed,.gpu-line.removed,.set-card.removed{opacity:.5}
|
||||
.gpu-row.unusable .name,.gpu-line.unusable .name,.set-card.unusable .name{color:var(--ember)}
|
||||
|
||||
/* the Mine page: the big switch and the three numbers */
|
||||
.hero-row{display:grid;grid-template-columns:1.3fr 1fr 1fr 1fr;gap:var(--gap-tile)}
|
||||
.toggle-big{display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:4px;min-height:112px;padding:18px 20px;border-radius:var(--tile-r);border:1px solid var(--ember);background:var(--ember);color:var(--ember-ink);cursor:pointer;text-align:left;transition:background .15s ease,border-color .15s ease,transform .15s ease,color .15s ease;min-width:0}
|
||||
.toggle-big:hover{background:var(--ember-hi);border-color:var(--ember-hi);transform:translateY(-1px)}
|
||||
.toggle-big:disabled{opacity:.45;cursor:default;transform:none}
|
||||
.toggle-big .ring{width:34px;height:34px;border-radius:50%;display:flex;align-items:center;justify-content:center;background:rgba(12,12,14,.18);margin-bottom:6px}
|
||||
.toggle-big .ring svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:2.2;stroke-linecap:round}
|
||||
.toggle-big .tl{font-family:var(--head);font-weight:700;font-size:18px;line-height:1.15;white-space:nowrap}
|
||||
.toggle-big .ts{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.06em;opacity:.8;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
|
||||
.toggle-big.stop{background:var(--graphite);border-color:var(--line-2);color:var(--bone)}
|
||||
.toggle-big.stop:hover{border-color:var(--ash);background:#1b1b20}
|
||||
.toggle-big.stop .ring{background:var(--ember-12);color:var(--ember)}
|
||||
.toggle-big.stop .ts{color:var(--molten);opacity:1}
|
||||
.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)}
|
||||
.strip.three{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.cell .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
|
||||
.cell .v{font-family:var(--head);font-weight:700;font-size:var(--t-num);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:var(--s-2);min-height:1.1em}
|
||||
.cell.ember .v{color:var(--ember)}
|
||||
.cell .v .unit{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);font-weight:500;letter-spacing:.08em}
|
||||
.cell .v.state{text-transform:capitalize;font-size:22px}
|
||||
.cell .v.small{font-size:18px}
|
||||
.cell .v.dim{color:var(--ash)}
|
||||
.cell .s{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cell.ok .v.state{color:var(--molten)}
|
||||
.cell.bad .v.state{color:var(--ember)}
|
||||
.cell.bad .s{color:var(--ember)}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:var(--gap);align-items:start}
|
||||
.card-head{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);margin-bottom:var(--s-3);flex-wrap:wrap}
|
||||
.stats{display:flex;flex-wrap:wrap;gap:12px 16px;font-size:var(--t-sm);color:var(--ash)}
|
||||
.stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums}
|
||||
#dag{display:block;width:100%;height:170px;border-radius:12px;background:var(--obsidian)}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:14px 18px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ink-2)}
|
||||
.legend span{display:inline-flex;align-items:center;gap:var(--s-2)}
|
||||
.sw{width:12px;height:12px;border-radius:3px;display:inline-block;border:1px solid var(--line-2)}
|
||||
.sw.ember{background:var(--ember);border-color:var(--ember)}
|
||||
.sw.glow{border-color:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)}
|
||||
.sw.line{width:18px;height:0;border:0;border-top:1px dashed var(--line-2);border-radius:0}
|
||||
.empty{color:var(--ash);font-size:var(--t-base);padding:10px 0}
|
||||
.empty.err{color:var(--ember)}
|
||||
.kv{display:flex;flex-direction:column}
|
||||
.kv>div{display:flex;justify-content:space-between;align-items:baseline;gap:var(--s-3);padding:7px 0;border-bottom:1px solid var(--line)}
|
||||
.kv>div:last-child{border-bottom:0}
|
||||
.kv .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.1em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
|
||||
.kv .v{font-size:var(--t-md);font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right}
|
||||
.kv .v.warn{color:var(--ember)}
|
||||
.card .note{margin-top:10px}
|
||||
.card .help+.help{margin-top:6px}
|
||||
.big-word{font-family:var(--head);font-weight:700;font-size:20px;line-height:1.2;margin:2px 0 8px;word-break:break-word}
|
||||
.big-word.ok{color:var(--molten)}
|
||||
.big-word.warn{color:var(--ember)}
|
||||
.big-word.dim{color:var(--ash)}
|
||||
.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);max-height:260px;overflow:auto}
|
||||
.feed>div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:8px 0;align-items:baseline}
|
||||
.feed>div:last-child{border-bottom:0}
|
||||
.feed>div>span:first-child{min-width:0}
|
||||
.feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)}
|
||||
.feed .k{color:var(--molten);margin-right:6px}
|
||||
.feed .k.error{color:var(--ember)}
|
||||
.feed .k.warn{color:var(--ember)}
|
||||
.feed .k.block{color:var(--ember)}
|
||||
.feed .k.build{color:var(--ink-2)}
|
||||
|
||||
/* the GPU rows on the Mine page: badge, name, numbers, the switch */
|
||||
.gpu-list{display:flex;flex-direction:column;gap:var(--s-2)}
|
||||
.gpu-line{display:grid;grid-template-columns:44px minmax(160px,1.4fr) repeat(3,minmax(84px,.7fr)) 48px;align-items:center;gap:var(--s-4);padding:12px 14px;border:1px solid var(--line);border-radius:var(--tile-r);background:var(--obsidian);min-width:0}
|
||||
.gpu-line.off{opacity:.62}
|
||||
.gpu-line .badge{width:44px;height:44px;flex-basis:44px;border-radius:12px}
|
||||
.gpu-line .who{min-width:0;display:flex;flex-direction:column;gap:3px}
|
||||
.gpu-line .name{font-family:var(--head);font-weight:700;font-size:var(--t-md);line-height:1.25;display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.gpu-line .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
|
||||
.gpu-line .st.on{color:var(--molten)}
|
||||
.gpu-line .st.bad{color:var(--ember)}
|
||||
.gpu-line .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block;flex:0 0 7px}
|
||||
.gpu-line .msg{font-size:var(--t-sm);color:var(--ember);line-height:1.4}
|
||||
.gpu-line .msg.dim{color:var(--ash)}
|
||||
.gpu-line .num{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.gpu-line .num .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
|
||||
.gpu-line .num .v{font-family:var(--head);font-weight:700;font-size:18px;line-height:1.15;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:5px}
|
||||
.gpu-line .num .v .unit{font-family:var(--mono);font-size:10px;color:var(--ash);font-weight:500;letter-spacing:.08em}
|
||||
.gpu-line .num .v.hot{color:var(--ember)}
|
||||
.gpu-line .num .v.warm{color:var(--molten)}
|
||||
.gpu-line .num .v.na{color:var(--ash);font-weight:500;font-size:var(--t-md)}
|
||||
.gpu-line.on .num.hash .v{color:var(--ember)}
|
||||
.gpu-line .switch{padding:0;justify-self:end}
|
||||
|
||||
/* the Settings page: per-card controls */
|
||||
.set-cards{display:flex;flex-direction:column;gap:var(--s-3);margin-bottom:var(--s-3)}
|
||||
.set-card{border:1px solid var(--line);border-radius:var(--tile-r);background:var(--obsidian);padding:14px 16px;display:flex;flex-direction:column;gap:10px;min-width:0}
|
||||
.set-card .head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.set-card .head .name{font-family:var(--head);font-weight:700;font-size:var(--t-md)}
|
||||
.set-card .head .meta{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash)}
|
||||
.set-card .ctl{display:grid;grid-template-columns:150px 1fr auto;align-items:center;gap:var(--s-3)}
|
||||
.set-card .ctl .k{font-size:var(--t-md);color:var(--bone);font-weight:500}
|
||||
.set-card .ctl .pv{font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);min-width:110px;text-align:right;white-space:nowrap}
|
||||
.set-card input[type=range]{width:100%;accent-color:var(--ember);margin:0}
|
||||
.set-card .ctl .help{grid-column:1 / -1;margin-top:-4px}
|
||||
.set-card .line{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center}
|
||||
.set-card .line b{color:var(--ink-2);font-weight:500}
|
||||
.set-card .line.ok{color:var(--molten)}
|
||||
.set-card .line.hot{color:var(--ember)}
|
||||
.set-card .line.on{color:var(--molten)}
|
||||
.set-card .line .pin{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line-2);border-radius:6px;padding:1px 6px}
|
||||
.ids{display:flex;align-items:center;gap:6px;flex:0 0 auto;justify-self:end}
|
||||
.ids button{width:30px;height:30px;border-radius:8px;border:1px solid var(--line-2);background:transparent;color:var(--bone);cursor:pointer;font-size:16px;line-height:1}
|
||||
.ids button:hover{border-color:var(--ash)}
|
||||
.ids input{width:44px;text-align:center;background:var(--obsidian);border:1px solid var(--line-2);border-radius:8px;padding:5px 4px;color:var(--bone);font-family:var(--mono);font-size:var(--t-base);user-select:text;-webkit-user-select:text}
|
||||
.ids input:focus{outline:none;border-color:var(--ember)}
|
||||
.ids input::-webkit-inner-spin-button,.ids input::-webkit-outer-spin-button{-webkit-appearance:none;margin:0}
|
||||
.gpu-row.off .ids{opacity:.4;pointer-events:none}
|
||||
.gpu-row .switch{padding:0;flex:0 0 auto}
|
||||
.cards.compact .gpu-row{padding:12px 14px;gap:var(--s-3);border-radius:14px}
|
||||
.cards.compact .gpu-row .badge{width:38px;height:38px;flex-basis:38px;border-radius:10px}
|
||||
.cards.compact .gpu-row .name{font-size:var(--t-md)}
|
||||
.cards.compact .gpu-row .info{flex-basis:calc(100% - 50px);min-width:120px}
|
||||
.cards.compact .gpu-row .power{margin-left:50px}
|
||||
.cards.compact .gpu-row .switch{margin-left:auto}
|
||||
.cards.compact .ids .k{display:none}
|
||||
|
||||
.power{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto}
|
||||
.power .k{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.power input[type=range]{width:110px;accent-color:var(--ember)}
|
||||
.power .pv{font-size:var(--t-sm);color:var(--ink-2);min-width:84px}
|
||||
.power .pin{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.08em;text-transform:uppercase;color:var(--ash);border:1px solid var(--line-2);border-radius:6px;padding:1px 6px}
|
||||
.gpu-tile .msg.sweep{color:var(--ash)}
|
||||
.gpu-tile .msg.sweep b{color:var(--ink-2);font-weight:500}
|
||||
.gpu-tile .msg.sweep.on{color:var(--molten)}
|
||||
.cards.compact .power .k{display:none}
|
||||
.cards.compact .power input[type=range]{width:80px}
|
||||
.gpu-tile .m.tele .warm,.gpu-tile .m.tele .warm b{color:var(--molten)}
|
||||
.gpu-tile .m.tele .hot,.gpu-tile .m.tele .hot b{color:var(--ember)}
|
||||
.gpu-tile .msg.ok,.gpu-row .msg.ok{color:var(--molten)}
|
||||
.gpu-row .msg.hot,.gpu-tile .msg.hot{color:var(--ember)}
|
||||
.gpu-tile .msg .btn.tiny,.gpu-row .msg .btn.tiny{margin-left:6px;vertical-align:middle}
|
||||
.gpu-tile .msg.warm{color:var(--molten)}
|
||||
.gpu-tile .msg.hot{color:var(--ember)}
|
||||
|
||||
/* per-card tiles on the dashboard */
|
||||
.gpu-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:var(--gap-tile)}
|
||||
.gpu-tile{background:var(--obsidian);border:1px solid var(--line);border-radius:var(--tile-r);padding:14px 16px;display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.gpu-tile .n{font-family:var(--head);font-weight:700;font-size:var(--t-md);line-height:1.25;display:flex;align-items:center;gap:var(--s-2);flex-wrap:wrap}
|
||||
.gpu-tile .h{font-family:var(--head);font-weight:700;font-size:24px;color:var(--ember);line-height:1.1;display:flex;align-items:baseline;gap:6px;font-variant-numeric:tabular-nums}
|
||||
.gpu-tile .h .unit{font-family:var(--mono);font-size:var(--t-xs);color:var(--ash);font-weight:500;letter-spacing:.08em}
|
||||
.gpu-tile.idle .h{color:var(--ash)}
|
||||
.gpu-tile .m{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);display:flex;flex-wrap:wrap;gap:4px 12px}
|
||||
.gpu-tile .m b{color:var(--ink-2);font-weight:500}
|
||||
.gpu-tile .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash)}
|
||||
.gpu-tile .st.mining{color:var(--molten)}
|
||||
.gpu-tile .st.bad{color:var(--ember)}
|
||||
.gpu-tile .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block}
|
||||
.gpu-tile .msg{font-size:var(--t-sm);color:var(--ash)}
|
||||
.gpu-off{margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ash)}
|
||||
.lead-card .lead-row,.lead-row{display:flex;align-items:flex-start;justify-content:space-between;gap:var(--s-4)}
|
||||
.lead-text{min-width:0;display:flex;flex-direction:column;gap:6px}
|
||||
.lead-text h3{font-size:var(--t-2xl)}
|
||||
.switch-list{display:flex;flex-direction:column;gap:3px;font-size:var(--t-sm);color:var(--ash);margin-top:8px}
|
||||
.switch-list span{display:flex;justify-content:space-between;gap:12px}
|
||||
.switch-list span.past{opacity:.55}
|
||||
.switch-list span.next{color:var(--molten)}
|
||||
.addr-big{display:flex;align-items:center;gap:12px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:12px;padding:14px 16px;font-size:var(--t-lg);word-break:break-all;user-select:text;-webkit-user-select:text;margin-bottom:10px}
|
||||
.addr-big span{flex:1;min-width:0;color:var(--molten)}
|
||||
.card.adv{padding:0}
|
||||
.card.adv summary{list-style:none;cursor:pointer;display:flex;align-items:center;justify-content:space-between;gap:12px;padding:var(--card-pad)}
|
||||
.card.adv summary::-webkit-details-marker{display:none}
|
||||
.card.adv summary::after{content:"+";font-family:var(--mono);color:var(--ash);font-size:18px;margin-left:auto}
|
||||
.card.adv[open] summary::after{content:"\2212"}
|
||||
.card.adv summary .eyebrow{margin-left:0}
|
||||
.card.adv>:not(summary){margin-left:var(--card-pad);margin-right:var(--card-pad)}
|
||||
.card.adv>:last-child{margin-bottom:var(--card-pad)}
|
||||
.job-history table{margin-top:var(--s-2)}
|
||||
table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
|
||||
.job-history th{text-align:left;font-weight:500;color:var(--ash);font-size:var(--t-xs);padding:4px 6px 4px 0;font-family:var(--mono);letter-spacing:.1em;text-transform:uppercase}
|
||||
.job-history td{padding:6px 6px 6px 0;border-top:1px solid var(--line);vertical-align:top}
|
||||
.job-history tr.failed td,.job-history tr.timeout td,.job-history tr.aborted td{color:var(--ember)}
|
||||
.job-history tr.running td{color:var(--molten)}
|
||||
.clock-card{border:1px solid rgba(242,84,27,.5);background:rgba(242,84,27,.08);border-radius:12px;padding:12px 14px;display:flex;flex-direction:column;gap:var(--s-2)}
|
||||
.clock-card.warn{border-color:var(--molten-40);background:rgba(255,179,92,.06)}
|
||||
.clock-msg{font-size:var(--t-md);color:var(--bone);line-height:1.45}
|
||||
.clock-card .note{margin-top:0}
|
||||
|
||||
/* address options */
|
||||
.options{display:flex;flex-direction:column;gap:var(--s-3);margin-top:6px}
|
||||
|
|
@ -244,63 +346,8 @@ body[data-phase="welcome"] #screen-welcome,body[data-phase="cards"] #screen-card
|
|||
.option:not(.on) .addr-input{display:none}
|
||||
.err{font-size:var(--t-base);color:var(--ember)}
|
||||
|
||||
/* dashboard */
|
||||
#screen-dashboard{padding:22px 0 28px;display:none;flex-direction:column;gap:var(--gap)}
|
||||
body[data-phase="dashboard"] #screen-dashboard{display:flex}
|
||||
.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)}
|
||||
.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.cell .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.12em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
|
||||
.cell .v{font-family:var(--head);font-weight:700;font-size:var(--t-num);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:baseline;gap:var(--s-2);min-height:1.1em}
|
||||
.cell.ember .v{color:var(--ember)}
|
||||
.cell .v .unit{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);font-weight:500;letter-spacing:.08em}
|
||||
.cell .v.state{text-transform:capitalize;font-size:22px}
|
||||
.cell .s{font-family:var(--mono);font-size:var(--t-sm);color:var(--ash);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cell.ok .v.state{color:var(--molten)}
|
||||
.cell.bad .v.state{color:var(--ember)}
|
||||
.cell.bad .s{color:var(--ember)}
|
||||
.grid{display:grid;grid-template-columns:1.35fr .85fr;gap:var(--gap);align-items:start}
|
||||
.col-main,.col-side{display:flex;flex-direction:column;gap:var(--gap);min-width:0}
|
||||
.card-head{display:flex;justify-content:space-between;align-items:center;gap:var(--s-3);margin-bottom:var(--s-3);flex-wrap:wrap}
|
||||
.stats{display:flex;flex-wrap:wrap;gap:12px 16px;font-size:var(--t-sm);color:var(--ash)}
|
||||
.stats b{color:var(--bone);font-weight:500;font-variant-numeric:tabular-nums}
|
||||
#dag{display:block;width:100%;height:190px;border-radius:12px;background:var(--obsidian)}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:14px 18px;margin-top:var(--s-3);font-size:var(--t-sm);color:var(--ink-2)}
|
||||
.legend span{display:inline-flex;align-items:center;gap:var(--s-2)}
|
||||
.sw{width:12px;height:12px;border-radius:3px;display:inline-block;border:1px solid var(--line-2)}
|
||||
.sw.ember{background:var(--ember);border-color:var(--ember)}
|
||||
.sw.glow{border-color:var(--molten);box-shadow:0 0 8px rgba(255,179,92,.7)}
|
||||
.sw.line{width:18px;height:0;border:0;border-top:1px dashed var(--line-2);border-radius:0}
|
||||
.tbl{overflow-x:auto}
|
||||
table{border-collapse:collapse;width:100%;font-size:var(--t-base)}
|
||||
th,td{padding:9px 8px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap}
|
||||
th{font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500}
|
||||
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums;font-family:var(--mono)}
|
||||
tr:last-child td{border-bottom:0}
|
||||
td .st{display:inline-flex;align-items:center;gap:7px;font-family:var(--mono);font-size:var(--t-sm);color:var(--ash)}
|
||||
td .st.mining{color:var(--molten)}
|
||||
td .st.bad{color:var(--ember)}
|
||||
td .st i{width:7px;height:7px;border-radius:50%;background:currentColor;display:inline-block}
|
||||
td .sub{display:block;font-family:var(--mono);font-size:var(--t-xs);color:var(--ash);white-space:normal;max-width:280px}
|
||||
.empty{color:var(--ash);font-size:var(--t-base);padding:10px 0}
|
||||
.kv{display:flex;flex-direction:column}
|
||||
.kv>div{display:flex;justify-content:space-between;align-items:baseline;gap:var(--s-3);padding:7px 0;border-bottom:1px solid var(--line)}
|
||||
.kv>div:last-child{border-bottom:0}
|
||||
.kv .k{font-family:var(--mono);font-size:var(--t-xs);letter-spacing:.1em;text-transform:uppercase;color:var(--ash);white-space:nowrap}
|
||||
.kv .v{font-size:var(--t-md);font-variant-numeric:tabular-nums;color:var(--bone);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;text-align:right}
|
||||
.kv .v.warn{color:var(--ember)}
|
||||
.card .note{margin-top:10px}
|
||||
.feed{display:flex;flex-direction:column;font-family:var(--mono);font-size:var(--t-sm);color:var(--ink-2);max-height:300px;overflow:auto}
|
||||
.feed>div{display:flex;justify-content:space-between;gap:10px;border-bottom:1px solid var(--line);padding:8px 0;align-items:baseline}
|
||||
.feed>div:last-child{border-bottom:0}
|
||||
.feed>div>span:first-child{min-width:0}
|
||||
.feed .t{color:var(--ash);flex:0 0 auto;font-size:var(--t-xs)}
|
||||
.feed .k{color:var(--molten);margin-right:6px}
|
||||
.feed .k.error{color:var(--ember)}
|
||||
.feed .k.block{color:var(--ember)}
|
||||
.feed .k.build{color:var(--ink-2)}
|
||||
|
||||
/* the update card (app.js, UpdateCard; the wallet carries the same block): the mark with its ring, the name, one
|
||||
line, up to three note lines, the size, Install now and Later. Over the key sheet and the settings panel. */
|
||||
line, up to three note lines, the size, Install now and Later. Over the key sheet and the pages. */
|
||||
.upd-wrap{position:fixed;inset:0;z-index:35;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px;animation:fade .25s ease}
|
||||
@keyframes fade{from{opacity:0}to{opacity:1}}
|
||||
.upd-card{position:relative;width:100%;max-width:500px;max-height:calc(100vh - 48px);overflow:auto;background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:34px 32px 28px;display:flex;flex-direction:column;align-items:center;text-align:center;gap:var(--s-2);box-shadow:0 30px 80px rgba(0,0,0,.6),0 0 0 1px rgba(242,84,27,.08);animation:rise .3s ease;outline:none}
|
||||
|
|
@ -334,7 +381,7 @@ td .sub{display:block;font-family:var(--mono);font-size:var(--t-xs);color:var(--
|
|||
@media (max-height:620px){.upd-card{padding:24px 24px 20px}.upd-mark{width:72px;height:72px;margin-bottom:var(--s-2)}.upd-mark img{width:32px;height:32px}.upd-name{font-size:20px}}
|
||||
|
||||
/* sheet (the key) */
|
||||
.sheet-wrap,.panel-wrap{position:fixed;inset:0;z-index:30;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px}
|
||||
.sheet-wrap{position:fixed;inset:0;z-index:30;background:rgba(12,12,14,.72);backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);display:flex;align-items:center;justify-content:center;padding:24px}
|
||||
.sheet{background:var(--graphite);border:1px solid var(--line-2);border-radius:22px;padding:32px;max-width:640px;width:100%;max-height:calc(100vh - 48px);overflow:auto;display:flex;flex-direction:column;gap:14px;box-shadow:0 30px 80px rgba(0,0,0,.6);animation:rise .3s ease}
|
||||
.sheet .sub{font-size:var(--t-lg);color:var(--ink-2)}
|
||||
.field{display:flex;flex-direction:column;gap:var(--s-2);margin-top:6px}
|
||||
|
|
@ -347,41 +394,29 @@ td .sub{display:block;font-family:var(--mono);font-size:var(--t-xs);color:var(--
|
|||
.check input{width:18px;height:18px;accent-color:var(--ember);margin:0}
|
||||
.check.small input{width:15px;height:15px}
|
||||
.sheet .cta{justify-content:flex-start}
|
||||
@media (prefers-reduced-motion:reduce){.sheet{animation:none}}
|
||||
|
||||
/* settings panel */
|
||||
.panel-wrap{justify-content:flex-end;padding:0}
|
||||
.panel{width:min(440px,100%);height:100%;background:var(--graphite);border-left:1px solid var(--line-2);display:flex;flex-direction:column;animation:slide .25s ease}
|
||||
@keyframes slide{from{transform:translateX(30px);opacity:0}to{transform:none;opacity:1}}
|
||||
@media (prefers-reduced-motion:reduce){.panel,.sheet{animation:none}}
|
||||
.panel-head{display:flex;justify-content:space-between;align-items:center;padding:18px 22px;border-bottom:1px solid var(--line);flex:0 0 auto}
|
||||
.panel-body{padding:14px 22px 28px;overflow:auto;display:flex;flex-direction:column;gap:18px;overscroll-behavior:contain}
|
||||
/* rows, switches */
|
||||
.row{display:flex;gap:10px;align-items:center;min-width:0}
|
||||
.row.between{justify-content:space-between}
|
||||
.row.wrap{flex-wrap:wrap}
|
||||
.row .addr-input{margin-top:0;min-width:0}
|
||||
.num{width:90px;background:var(--obsidian);border:1px solid var(--line-2);border-radius:10px;padding:9px 12px;color:var(--bone);font-size:var(--t-md);user-select:text;-webkit-user-select:text}
|
||||
.num:focus{outline:none;border-color:var(--ember)}
|
||||
.switch{display:flex;align-items:center;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:6px 0;line-height:1.35}
|
||||
.switch{display:flex;align-items:center;gap:var(--s-3);font-size:var(--t-md);cursor:pointer;padding:8px 0 2px;line-height:1.35}
|
||||
.switch input{position:absolute;opacity:0;width:0;height:0}
|
||||
.switch .track{width:40px;height:22px;border-radius:999px;background:var(--line-2);position:relative;flex:0 0 40px;transition:background .15s ease}
|
||||
.switch .track::after{content:"";position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:var(--bone);transition:transform .15s ease}
|
||||
.switch input:checked+.track{background:var(--ember)}
|
||||
.switch input:checked+.track::after{transform:translateX(18px)}
|
||||
.switch input:focus-visible+.track{outline:2px solid var(--ember);outline-offset:3px}
|
||||
|
||||
/* bottom bar */
|
||||
.bottom{position:fixed;left:0;right:0;bottom:0;height:var(--bottom);display:flex;align-items:center;justify-content:space-between;gap:var(--s-3);padding:0 var(--gutter);background:rgba(12,12,14,.92);border-top:1px solid var(--line);z-index:21}
|
||||
.bottom .left,.bottom .right{display:flex;align-items:center;gap:var(--s-2);flex:0 0 auto}
|
||||
.bottom .mid{font-size:var(--t-sm);color:var(--ash);display:flex;align-items:center;justify-content:center;gap:0;min-width:0;flex:1;overflow:hidden;white-space:nowrap}
|
||||
.bottom .mid .pc{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis}
|
||||
.bottom .mid .pc+.pc::before{content:"·";margin:0 7px;color:var(--line-2)}
|
||||
.bottom .mid .nm{color:var(--ink-2);max-width:220px;flex-shrink:1}
|
||||
.bottom .mid .ad{flex-shrink:0}
|
||||
.bottom .right .mono{font-size:var(--t-sm)}
|
||||
@media (max-width:1100px){.bottom .mid .up{display:none}.bottom .mid .nm{max-width:140px}.log-tools .at{display:none}.log-search{flex-basis:120px;max-width:200px}}
|
||||
@media (max-width:960px){.bottom .mid .ad{display:none}}
|
||||
.switch input:disabled+.track{opacity:.4}
|
||||
.switch.lg .track{width:52px;height:30px;flex-basis:52px}
|
||||
.switch.lg .track::after{width:24px;height:24px}
|
||||
.switch.lg input:checked+.track::after{transform:translateX(22px)}
|
||||
.switch+.help{margin-bottom:6px}
|
||||
|
||||
/* log drawer */
|
||||
.drawer{position:fixed;left:0;right:0;bottom:var(--bottom);height:0;overflow:hidden;background:var(--obsidian);border-top:1px solid var(--line);z-index:20;transition:height .2s ease;display:flex;flex-direction:column;box-shadow:0 -12px 30px rgba(0,0,0,.35)}
|
||||
.drawer{position:fixed;left:0;right:0;bottom:0;height:0;overflow:hidden;background:var(--obsidian);border-top:1px solid var(--line);z-index:21;transition:height .2s ease;display:flex;flex-direction:column;box-shadow:0 -12px 30px rgba(0,0,0,.35)}
|
||||
body.has-rail .drawer{left:var(--rail)}
|
||||
body.drawer-open .drawer{height:var(--drawer-h)}
|
||||
body.drawer-drag .drawer{transition:none}
|
||||
body.drawer-drag{cursor:row-resize}
|
||||
|
|
@ -435,28 +470,56 @@ body.drawer-drag main{pointer-events:none}
|
|||
.log-jump{position:absolute;right:calc(var(--gutter) + 14px);bottom:14px;background:var(--graphite);border-color:var(--molten-40);color:var(--molten);box-shadow:0 8px 24px rgba(0,0,0,.5);z-index:2;gap:6px;padding:6px 12px}
|
||||
.log-jump:hover{background:#1c1a18;border-color:var(--molten)}
|
||||
|
||||
.toast{position:fixed;left:50%;bottom:calc(var(--bottom) + 16px);transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:var(--t-base);z-index:40;box-shadow:0 10px 30px rgba(0,0,0,.5);max-width:min(90vw,520px);text-align:center}
|
||||
body.drawer-open .toast{bottom:calc(var(--bottom) + var(--drawer-h) + 16px)}
|
||||
.toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);background:var(--graphite);border:1px solid var(--line-2);border-radius:10px;padding:10px 16px;font-size:var(--t-base);z-index:40;box-shadow:0 10px 30px rgba(0,0,0,.5);max-width:min(90vw,520px);text-align:center}
|
||||
body.has-rail .toast{left:calc(50% + var(--rail) / 2)}
|
||||
body.drawer-open .toast{bottom:calc(var(--drawer-h) + 16px)}
|
||||
|
||||
/* narrow windows: the strip folds to two columns, the side column drops under the main one */
|
||||
@media (max-width:860px){
|
||||
/* narrow windows (the 900 px floor): the rail folds to icons with small labels, the hero row and the strips fold to
|
||||
two columns, the two-column grid to one, the GPU rows drop a number */
|
||||
@media (max-width:1180px){
|
||||
.hero-row{grid-template-columns:1fr 1fr}
|
||||
.toggle-big{grid-row:span 1}
|
||||
.strip{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.grid{grid-template-columns:1fr}
|
||||
.strip.three{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
.grid2{grid-template-columns:1fr}
|
||||
.top-status{max-width:220px}
|
||||
}
|
||||
@media (max-width:1000px){
|
||||
:root{--rail:76px;--gutter:var(--s-5)}
|
||||
.rail{padding:12px 8px}
|
||||
.rail-brand{justify-content:center;padding:6px 0 14px}
|
||||
.rail-brand .word{display:none}
|
||||
.nav{flex-direction:column;gap:4px;padding:8px 4px;font-size:10px;letter-spacing:.06em;text-transform:uppercase;font-family:var(--mono);font-weight:500;text-align:center;min-height:52px;justify-content:center}
|
||||
.nav.on{font-weight:500}
|
||||
.nav.on::before{left:-9px}
|
||||
.nav.small{min-height:44px}
|
||||
.nav-dot{right:8px;top:8px;margin:0}
|
||||
.rail-status{display:none}
|
||||
.rail-version{text-align:center;padding:8px 0 0;font-size:10px;white-space:nowrap}
|
||||
.rail-version .chain{display:none}
|
||||
.gpu-line{grid-template-columns:44px minmax(140px,1.4fr) repeat(2,minmax(80px,.7fr)) 48px}
|
||||
.gpu-line .num.power{display:none}
|
||||
.page-sub{display:none}
|
||||
.top-status{display:none}
|
||||
.strip.three{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.set-card .ctl{grid-template-columns:120px 1fr auto}
|
||||
}
|
||||
@media (max-width:860px){
|
||||
.three{grid-template-columns:1fr}
|
||||
h1{font-size:40px}
|
||||
}
|
||||
/* short windows (under 700 px): tighter paddings, a lower hero, a smaller canvas, so the bottom bar and the drawer
|
||||
always have room */
|
||||
/* short windows (the 600 px floor): tighter paddings, a lower hero, a smaller canvas, so the drawer always has room */
|
||||
@media (max-height:700px){
|
||||
:root{--card-pad:18px;--tile-pad:14px 16px;--gap:var(--s-4)}
|
||||
#screen-dashboard{padding:16px 0 20px}
|
||||
#dag{height:150px}
|
||||
#dag{height:140px}
|
||||
.hero{gap:var(--s-3);padding:var(--s-5) 0 var(--s-6)}
|
||||
.coin-wrap{width:104px;height:104px}
|
||||
.coin{width:104px;height:104px}
|
||||
h1{font-size:40px}
|
||||
.lead{font-size:var(--t-xl)}
|
||||
.step{padding:32px 0 32px}
|
||||
.feed{max-height:220px}
|
||||
.feed{max-height:200px}
|
||||
.drawer-head{padding-bottom:6px}
|
||||
.toggle-big{min-height:96px}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,24 +8,47 @@
|
|||
<link rel="icon" href="mark.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
</head>
|
||||
<body class="phase-welcome" data-phase="welcome">
|
||||
<body class="phase-welcome" data-phase="welcome" data-page="mine">
|
||||
|
||||
<!-- the rail: one button per section (miner-ui-2). Shown on the dashboard; the setup screens have no rail. -->
|
||||
<aside class="rail" id="rail" hidden>
|
||||
<div class="rail-brand">
|
||||
<img src="mark.svg" width="28" height="28" alt="">
|
||||
<span class="word">IGNEUM</span>
|
||||
</div>
|
||||
<nav class="rail-nav" id="rail-nav" aria-label="Sections">
|
||||
<button class="nav on" data-page="mine" aria-current="page"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/></svg><span>Mine</span></button>
|
||||
<button class="nav" data-page="prove"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 4 5v6c0 5 3.4 9.4 8 11 4.6-1.6 8-6 8-11V5l-8-3z"/><path d="m9 12 2 2 4-4"/></svg><span>Prove</span></button>
|
||||
<button class="nav" data-page="rewards"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 10h18M16 15h2"/></svg><span>Rewards</span></button>
|
||||
<button class="nav" data-page="node"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c3 3.5 3 14.5 0 18M12 3c-3 3.5-3 14.5 0 18"/></svg><span>Node</span></button>
|
||||
<button class="nav" data-page="updates"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 4v11M7 10l5 5 5-5"/><path d="M4 19h16"/></svg><span>Updates</span><i class="nav-dot" id="nav-updates-dot" hidden></i></button>
|
||||
<button class="nav" data-page="settings"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 7h10M18 7h2M4 17h4M12 17h8"/><circle cx="16" cy="7" r="2"/><circle cx="10" cy="17" r="2"/></svg><span>Settings</span></button>
|
||||
</nav>
|
||||
<div class="rail-foot">
|
||||
<div class="rail-status mono" id="rail-status"></div>
|
||||
<button class="nav small" id="btn-logs" aria-expanded="false" aria-controls="drawer"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h10"/></svg><span id="btn-logs-text">Logs</span></button>
|
||||
<button class="nav small danger" id="btn-quit"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v9"/><path d="M6.5 6.5a8 8 0 1 0 11 0"/></svg><span>Quit</span></button>
|
||||
<div class="rail-version mono" id="foot-version"></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<header class="top">
|
||||
<div class="brand">
|
||||
<div class="brand" id="top-brand">
|
||||
<img src="mark.svg" width="30" height="30" alt="">
|
||||
<span class="word">IGNEUM</span><span class="miner">MINER</span>
|
||||
</div>
|
||||
<div class="page-title" id="page-title" hidden>
|
||||
<h1 id="page-title-text">Mine</h1>
|
||||
<span class="page-sub" id="page-sub"></span>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<span class="top-status mono" id="top-status"></span>
|
||||
<div class="pill" id="pill"><span class="dot"></span><span id="pill-text">starting</span></div>
|
||||
<button class="icon-btn" id="btn-settings" title="Settings" aria-label="Settings" hidden>
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3h.1a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8v.1a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- the notice strip: one notice at a time (updates, remote jobs, the clock), the most important first; app.js fills
|
||||
it from the state and the content below moves once when it appears or goes. The close control hides a notice
|
||||
until its state moves on. -->
|
||||
<!-- the status strip: one notice at a time (updates, remote jobs, the clock), the most important first; app.js fills
|
||||
it from the state (Notices) and the content below moves once when it appears or goes. -->
|
||||
<div class="notices" id="notices" hidden>
|
||||
<div class="notice" id="notice" role="status" aria-live="polite">
|
||||
<span class="notice-text" id="notice-text"></span>
|
||||
|
|
@ -85,8 +108,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<p class="note" id="cards-note" hidden></p>
|
||||
<p class="note" id="cards-power" hidden>Igneum caps each NVIDIA card's power at 80% of its default limit to keep it stable (an RTX 5090 at full power hard-crashed in the field). This needs administrator rights once, when mining starts; the limit goes back to what it was on quit. The slider sets the cap per card. In the first hour, and then weekly, the efficiency sweep steps the cap from 100% down to 50% on the live program and holds the best MH per watt; a cap you set by hand stays pinned.</p>
|
||||
<p class="note" id="cards-help" hidden>Each card you switch on gets its own worker. Identities take turns on the card and each one votes and pays separately; 8 suits a big card, 2 a small one, 1 an integrated GPU.</p>
|
||||
<p class="note" id="cards-power" hidden>Igneum caps each NVIDIA card's power at 80% of its default limit to keep it stable. This needs administrator rights once, when mining starts; the limit goes back to what it was on quit. Settings has a slider per card.</p>
|
||||
<p class="note" id="cards-help" hidden>Each card you switch on gets its own worker. An integrated GPU is off by default: it is slow and shares the machine's memory.</p>
|
||||
<div class="cta">
|
||||
<button class="btn primary" id="btn-cards-next" disabled>Continue</button>
|
||||
<button class="btn ghost" id="btn-cards-retry" hidden>Detect again</button>
|
||||
|
|
@ -128,64 +151,168 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. dashboard -->
|
||||
<!-- 4. the dashboard: six pages behind the rail -->
|
||||
<section class="screen" id="screen-dashboard">
|
||||
<div class="strip">
|
||||
<div class="cell big ember">
|
||||
<div class="k">hash rate</div>
|
||||
<div class="v"><span id="d-hash">0.0</span><span class="unit">MH/s</span></div>
|
||||
<div class="s" id="d-hash-sub">waiting for the worker</div>
|
||||
</div>
|
||||
<div class="cell big">
|
||||
<div class="k">blocks found</div>
|
||||
<div class="v" id="d-blocks">0</div>
|
||||
<div class="s" id="d-blocks-sub">accepted by the node</div>
|
||||
</div>
|
||||
<div class="cell big" id="d-node-cell">
|
||||
<div class="k">node</div>
|
||||
<div class="v state" id="d-node">starting</div>
|
||||
<div class="s" id="d-node-sub">opening the database</div>
|
||||
</div>
|
||||
<div class="cell big">
|
||||
<div class="k">next program</div>
|
||||
<div class="v" id="d-eta">--:--</div>
|
||||
<div class="s" id="d-eta-sub">waiting for the node</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="col-main">
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<div class="eyebrow"><span class="dot small"></span>your blocks</div>
|
||||
<div class="stats mono"><span>last 10 min <b id="d-found-10">0</b></span><span>last hour <b id="d-found-60">0</b></span><span>dev fee <b id="d-fee">0</b></span><span>chain <b id="d-chain-blocks">0</b></span></div>
|
||||
</div>
|
||||
<canvas id="dag" aria-hidden="true"></canvas>
|
||||
<div class="legend"><span><i class="sw ember"></i>block this machine found</span><span><i class="sw glow"></i>just accepted</span><span><i class="sw line"></i>one minute</span></div>
|
||||
<!-- Mine -->
|
||||
<section class="page" id="page-mine" data-page="mine">
|
||||
<div class="hero-row">
|
||||
<button class="toggle-big" id="btn-toggle" disabled>
|
||||
<span class="ring"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v9"/><path d="M6.5 6.5a8 8 0 1 0 11 0"/></svg></span>
|
||||
<span class="tl" id="btn-toggle-text">Start mining</span>
|
||||
<span class="ts" id="btn-toggle-sub">waiting for the engine</span>
|
||||
</button>
|
||||
<div class="cell big ember">
|
||||
<div class="k">hash rate</div>
|
||||
<div class="v"><span id="d-hash">0.0</span><span class="unit">MH/s</span></div>
|
||||
<div class="s" id="d-hash-sub">waiting for the worker</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Cards</h3><div class="eyebrow" id="d-cards-eyebrow">1 worker</div></div>
|
||||
<div class="gpu-tiles" id="d-cards"><div class="empty">No card yet.</div></div>
|
||||
<div class="gpu-off mono" id="d-cards-off" hidden></div>
|
||||
<div class="cell big">
|
||||
<div class="k">blocks found</div>
|
||||
<div class="v" id="d-blocks">0</div>
|
||||
<div class="s" id="d-blocks-sub">accepted by the node</div>
|
||||
</div>
|
||||
<div class="cell big">
|
||||
<div class="k">next program</div>
|
||||
<div class="v" id="d-eta">--:--</div>
|
||||
<div class="s" id="d-eta-sub">waiting for the node</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-side">
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Your GPUs</h3><div class="eyebrow" id="d-cards-eyebrow">detecting</div></div>
|
||||
<div class="gpu-list" id="d-cards"><div class="empty">Waiting for the engine.</div></div>
|
||||
<p class="note" id="d-cards-note" hidden></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h3>Your blocks</h3>
|
||||
<div class="stats mono"><span>last 10 min <b id="d-found-10">0</b></span><span>last hour <b id="d-found-60">0</b></span><span>dev fee <b id="d-fee">0</b></span><span>chain <b id="d-chain-blocks">0</b></span></div>
|
||||
</div>
|
||||
<canvas id="dag" aria-hidden="true"></canvas>
|
||||
<div class="legend"><span><i class="sw ember"></i>block this machine found</span><span><i class="sw glow"></i>just accepted</span><span><i class="sw line"></i>one minute</span></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Activity</h3><div class="eyebrow">newest first</div></div>
|
||||
<div class="feed" id="d-events"><div class="empty">No events yet.</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Prove -->
|
||||
<section class="page" id="page-prove" data-page="prove" hidden>
|
||||
<div class="card lead-card">
|
||||
<div class="lead-row">
|
||||
<div class="lead-text">
|
||||
<h3>Prove shards on this machine</h3>
|
||||
<p class="help">Every block on Igneum is turned into a short mathematical proof, in pieces called shards. The chain assigns shards to your keys; this machine proves them and is paid for each one.</p>
|
||||
</div>
|
||||
<label class="switch lg" title="Prove assigned shards"><input type="checkbox" id="s-prove" aria-label="Prove shards on this machine"><span class="track"></span></label>
|
||||
</div>
|
||||
<p class="note" id="pv-note">Off. Switch it on and this machine proves the shards the chain assigns to its keys.</p>
|
||||
<div class="row" id="pv-setup-row" hidden><button class="btn small primary" id="pv-setup">Set up</button><span class="note">About 20 minutes, once.</span></div>
|
||||
</div>
|
||||
<div class="strip four">
|
||||
<div class="cell"><div class="k">state</div><div class="v state" id="pv-state">off</div><div class="s" id="pv-state-sub">not proving</div></div>
|
||||
<div class="cell"><div class="k">assigned</div><div class="v" id="pv-assigned">0</div><div class="s">shards given to your keys</div></div>
|
||||
<div class="cell"><div class="k">proven</div><div class="v" id="pv-submitted">0</div><div class="s">proofs sent to the node</div></div>
|
||||
<div class="cell"><div class="k">paid</div><div class="v" id="pv-paid">0</div><div class="s" id="pv-paid-sub">shards paid out</div></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Node</h3><div class="eyebrow" id="d-node-net">devnet v4</div></div>
|
||||
<div class="card-head"><h3>Verifier</h3><div class="eyebrow">the node's check</div></div>
|
||||
<div class="big-word" id="pv-verifier">not read yet</div>
|
||||
<p class="help" id="pv-verifier-help">Before a proof counts, the node checks it. A node without a verifier passes proofs along and never includes them.</p>
|
||||
<p class="note" id="pv-verifier-note" hidden></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Program</h3><div class="eyebrow">pinned guest</div></div>
|
||||
<div class="field">
|
||||
<div class="k">shard program id</div>
|
||||
<div class="box mono"><span id="pv-program">not read yet</span><button class="btn tiny" data-copy="pv-program">Copy</button></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">aggregator id</div>
|
||||
<div class="box mono"><span id="pv-aggregator">not read yet</span><button class="btn tiny" data-copy="pv-aggregator">Copy</button></div>
|
||||
</div>
|
||||
<p class="help">Every proof names the program that made it. Other nodes accept a proof only from these two ids.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Rewards -->
|
||||
<section class="page" id="page-rewards" data-page="rewards" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Rewards address</h3><div class="eyebrow" id="r-source"></div></div>
|
||||
<div class="addr-big mono"><span id="r-address">not set</span><button class="btn small" data-copy="r-address">Copy</button></div>
|
||||
<p class="help" id="r-address-help">Every block this machine finds pays this address.</p>
|
||||
</div>
|
||||
<div class="strip three">
|
||||
<div class="cell"><div class="k">blocks found</div><div class="v" id="r-blocks">0</div><div class="s">lifetime, accepted by the node</div></div>
|
||||
<div class="cell"><div class="k">this run</div><div class="v" id="r-session">0</div><div class="s" id="r-session-sub">since the app started</div></div>
|
||||
<div class="cell"><div class="k">balance</div><div class="v dim" id="r-balance">--</div><div class="s">shown in the wallet, not here yet</div></div>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="card" id="r-key-card">
|
||||
<div class="card-head"><h3>Save your key</h3><div class="eyebrow ember">once</div></div>
|
||||
<p class="help" id="r-key-help">The key for this address was made on this machine and is stored in the app folder, readable by your user only. Keep a copy somewhere safe: anyone with the key can spend what the address holds.</p>
|
||||
<div class="row" id="r-key-row"><button class="btn small" id="s-reveal">Show my key</button><button class="btn small ghost" id="s-hide" hidden>Hide</button></div>
|
||||
<div class="box mono key" id="s-key-box" hidden><span id="s-key"></span><button class="btn tiny" data-copy="s-key">Copy</button></div>
|
||||
<p class="note mono small" id="r-key-file"></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Use another address</h3></div>
|
||||
<p class="help">Paste an EVM address you control. The miner restarts and pays the new address from the next block.</p>
|
||||
<div class="row">
|
||||
<input type="text" class="addr-input mono" id="s-address-input" placeholder="0x" spellcheck="false" autocomplete="off" aria-label="New rewards address">
|
||||
<button class="btn small" id="s-address-save">Change</button>
|
||||
</div>
|
||||
<div class="err" id="s-address-err" hidden></div>
|
||||
<p class="note" id="r-devfee-line"></p>
|
||||
<div class="row"><button class="btn small ghost" id="r-wallet">Open the wallet page</button></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Node -->
|
||||
<section class="page" id="page-node" data-page="node" hidden>
|
||||
<div class="strip four">
|
||||
<div class="cell" id="n-state-cell"><div class="k">node</div><div class="v state" id="d-node">starting</div><div class="s" id="d-node-sub">opening the database</div></div>
|
||||
<div class="cell"><div class="k">height</div><div class="v" id="n-blocks">0</div><div class="s" id="n-blocks-sub">blocks this node holds</div></div>
|
||||
<div class="cell"><div class="k">peers</div><div class="v" id="n-peers">0</div><div class="s" id="n-peers-sub">other nodes it talks to</div></div>
|
||||
<div class="cell"><div class="k">version</div><div class="v small" id="n-version">--</div><div class="s" id="d-node-net">devnet v4</div></div>
|
||||
</div>
|
||||
<div class="clock-card" id="n-clock" hidden>
|
||||
<p class="clock-msg" id="n-clock-msg"></p>
|
||||
<div class="row"><button class="btn small primary" id="n-clock-sync">Sync clock</button><span class="note mono small" id="n-clock-result"></span></div>
|
||||
<p class="note" id="n-clock-hint"></p>
|
||||
</div>
|
||||
<div class="grid2">
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Chain</h3><div class="eyebrow" id="n-reading">reading</div></div>
|
||||
<div class="kv">
|
||||
<div><span class="k">height</span><span class="v mono" id="n-blocks">0</span></div>
|
||||
<div><span class="k">headers</span><span class="v mono" id="n-headers">0</span></div>
|
||||
<div><span class="k">peers</span><span class="v mono" id="n-peers">0</span></div>
|
||||
<div><span class="k">daa score</span><span class="v mono" id="n-daa">0</span></div>
|
||||
<div><span class="k">difficulty</span><span class="v mono" id="n-diff">0</span></div>
|
||||
<div><span class="k">tips</span><span class="v mono" id="n-tips">0</span></div>
|
||||
<div><span class="k">blue score</span><span class="v mono" id="n-blue">0</span></div>
|
||||
</div>
|
||||
<div class="clock-card" id="n-clock" hidden>
|
||||
<p class="clock-msg" id="n-clock-msg"></p>
|
||||
<div class="row"><button class="btn small primary" id="n-clock-sync">Sync clock</button><span class="note mono small" id="n-clock-result"></span></div>
|
||||
<p class="note" id="n-clock-hint"></p>
|
||||
<p class="help">Headers arrive before blocks. The DAA score counts blocks the whole network made; the difficulty is how hard the next one is to find.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Consensus</h3><div class="eyebrow">the rules</div></div>
|
||||
<div class="field">
|
||||
<div class="k">digest</div>
|
||||
<div class="box mono"><span id="n-digest">not printed yet</span><button class="btn tiny" data-copy="n-digest">Copy</button></div>
|
||||
<p class="help">The fingerprint of the rules this node runs. Every node on the network shows the same one; a peer with another is refused.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">next switch</div>
|
||||
<div class="big-word" id="n-switch">none planned</div>
|
||||
<p class="help" id="n-switch-help">A switch is a planned rule change. The node applies it by itself when the chain reaches that height.</p>
|
||||
<div class="switch-list mono" id="n-switches"></div>
|
||||
</div>
|
||||
<p class="note" id="n-note"></p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Finality</h3><div class="eyebrow">miner-only</div></div>
|
||||
|
|
@ -194,27 +321,93 @@
|
|||
<div><span class="k">age</span><span class="v mono" id="f-age">n/a</span></div>
|
||||
<div><span class="k">votes sent</span><span class="v mono" id="f-votes">0</span></div>
|
||||
</div>
|
||||
<p class="note" id="f-note">Locks appear once the miner votes on checkpoints.</p>
|
||||
</div>
|
||||
<div class="tile" id="tile-proving">
|
||||
<div class="h">Proving</div>
|
||||
<div class="kv">
|
||||
<div><span class="k">state</span><span class="v mono" id="pv-state">off</span></div>
|
||||
<div><span class="k">assigned</span><span class="v mono" id="pv-assigned">0</span></div>
|
||||
<div><span class="k">submitted</span><span class="v mono" id="pv-submitted">0</span></div>
|
||||
<div><span class="k">paid</span><span class="v mono" id="pv-paid">0</span></div>
|
||||
<div><span class="k">verifier</span><span class="v mono" id="pv-verifier">not read yet</span></div>
|
||||
</div>
|
||||
<p class="note" id="pv-note">Off. Settings switches it on: this machine proves the shards the chain assigns to its keys.</p>
|
||||
<p class="note" id="pv-verifier-note"></p>
|
||||
<div class="row" id="pv-setup-row" hidden><button class="btn small" id="pv-setup">Set up</button></div>
|
||||
<p class="help" id="f-note">A lock is a point the miners have agreed can never be undone. This machine votes on one every 30 s.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Events</h3><div class="eyebrow">newest first</div></div>
|
||||
<div class="feed" id="d-events"><div class="empty">No events yet.</div></div>
|
||||
<div class="card-head"><h3>Sync</h3><div class="eyebrow" id="n-sync-eyebrow"></div></div>
|
||||
<div class="big-word" id="n-sync-word">starting</div>
|
||||
<p class="help" id="n-note"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Updates -->
|
||||
<section class="page" id="page-updates" data-page="updates" hidden>
|
||||
<div class="card lead-card">
|
||||
<div class="lead-row">
|
||||
<div class="lead-text">
|
||||
<h3 id="s-version">Igneum Miner</h3>
|
||||
<p class="help" id="s-update-note">Not checked yet.</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<button class="btn small primary" id="s-install" hidden>Install now</button>
|
||||
<button class="btn small" id="s-update">Check now</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="switch"><input type="checkbox" id="s-auto-update"><span class="track"></span><span>Install updates by itself</span></label>
|
||||
<p class="help">Downloads in the background and installs at a quiet moment, never mid-program. Off: it downloads, then waits for Install now.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="lead-row">
|
||||
<div class="lead-text">
|
||||
<h3>Remote jobs</h3>
|
||||
<p class="help">Igneum publishes signed jobs (a benchmark, a script, logs to collect) next to the update manifest. This machine runs each one once and reports back. Only jobs signed by Igneum's key run.</p>
|
||||
</div>
|
||||
<button class="btn small" id="s-jobs-check">Check now</button>
|
||||
</div>
|
||||
<p class="note" id="s-jobs-note"></p>
|
||||
<div class="job-history" id="s-jobs-history"></div>
|
||||
<p class="note mono small" id="s-jobs-key"></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Settings -->
|
||||
<section class="page" id="page-settings" data-page="settings" hidden>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Graphics cards</h3><div class="eyebrow" id="s-cards-eyebrow"></div></div>
|
||||
<div class="set-cards" id="s-cards"><div class="empty">No card yet.</div></div>
|
||||
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span>Find each NVIDIA card's best efficiency</span></label>
|
||||
<p class="help">Once after install, then weekly, the power cap steps from 100% down to 50% and holds the step with the most hashes per watt. A cap you set by hand is left alone.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>This machine</h3></div>
|
||||
<label class="switch"><input type="checkbox" id="s-login"><span class="track"></span><span>Start at login</span></label>
|
||||
<p class="help">The miner opens when you sign in and keeps mining in the background.</p>
|
||||
<label class="switch"><input type="checkbox" id="s-jobs-allow"><span class="track"></span><span>Allow remote jobs from Igneum</span></label>
|
||||
<p class="help">Signed jobs from Igneum run on this machine and report back. Updates shows what ran.</p>
|
||||
<label class="switch"><input type="checkbox" id="s-vote"><span class="track"></span><span>Vote on finality checkpoints</span></label>
|
||||
<p class="help">Your miner signs a checkpoint every 30 s. Votes are what lock the chain; leave it on.</p>
|
||||
<div class="field">
|
||||
<div class="k">name</div>
|
||||
<div class="row">
|
||||
<input type="text" class="addr-input" id="s-name" placeholder="a name for this machine" maxlength="40" spellcheck="false" aria-label="Machine name">
|
||||
<button class="btn small" id="s-name-save">Rename</button>
|
||||
</div>
|
||||
<p class="help">A label for you only. Keys come from the machine id <span class="mono" id="s-mid"></span>, never from the name.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Dev fee</h3></div>
|
||||
<label class="switch"><input type="checkbox" id="s-devfee"><span class="track"></span><span id="s-devfee-text">Dev fee 1% (1 block in 100)</span></label>
|
||||
<p class="help" id="s-devfee-note">One block in 100 is mined for the miner software's author, the same way every GPU miner takes a fee. The protocol itself takes nothing. This switch turns it off.</p>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-head"><h3>Logs</h3></div>
|
||||
<div class="row wrap">
|
||||
<button class="btn small" id="s-log-copy">Copy the log</button>
|
||||
<button class="btn small ghost" id="s-log-open">Show the log</button>
|
||||
</div>
|
||||
<p class="help">Copies the last lines the node and the miner wrote, for a support message. Show the log opens the drawer with every line.</p>
|
||||
<p class="note mono small" id="s-log-dir"></p>
|
||||
<p class="note mono small" id="s-node-dir"></p>
|
||||
</div>
|
||||
<details class="card adv" id="s-advanced">
|
||||
<summary><h3>Advanced</h3><span class="eyebrow">devnet tools</span></summary>
|
||||
<label class="switch"><input type="checkbox" id="s-trust"><span class="track"></span><span>Trust proof records without verifying them</span></label>
|
||||
<p class="help" id="s-trust-note">Devnet only. When no verifier is found next to the engine, the node includes proof records it never checked. A found verifier always wins. Changing this restarts the node.</p>
|
||||
<div class="row"><button class="btn small ghost" id="s-live" hidden>Open the live devnet page</button></div>
|
||||
</details>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
|
@ -252,7 +445,7 @@
|
|||
<div class="k">private key</div>
|
||||
<div class="box mono key"><span id="key-private"></span><button class="btn tiny" data-copy="key-private">Copy</button></div>
|
||||
</div>
|
||||
<p class="note">Stored at <span class="mono" id="key-file"></span>, readable by your user only. Settings can show it again.</p>
|
||||
<p class="note">Stored at <span class="mono" id="key-file"></span>, readable by your user only. Rewards can show it again.</p>
|
||||
<p class="note">On devnet the vote keys are test keys derived from the miner's label. Mainnet vote keys will be random and stored like this wallet.</p>
|
||||
<label class="check"><input type="checkbox" id="key-ack"><span>I have saved my key</span></label>
|
||||
<div class="cta">
|
||||
|
|
@ -261,75 +454,6 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- settings -->
|
||||
<div class="panel-wrap" id="settings" hidden>
|
||||
<aside class="panel">
|
||||
<div class="panel-head"><h3>Settings</h3><button class="icon-btn" id="btn-settings-close" aria-label="Close">×</button></div>
|
||||
<div class="panel-body">
|
||||
<div class="field">
|
||||
<div class="k">rewards address</div>
|
||||
<div class="box mono"><span id="s-address"></span><button class="btn tiny" data-copy="s-address">Copy</button></div>
|
||||
<div class="row">
|
||||
<input type="text" class="addr-input mono" id="s-address-input" placeholder="paste a new address" spellcheck="false" autocomplete="off">
|
||||
<button class="btn small" id="s-address-save">Change</button>
|
||||
</div>
|
||||
<div class="err" id="s-address-err" hidden></div>
|
||||
<button class="btn small ghost" id="s-reveal" hidden>Show my key</button>
|
||||
<div class="box mono key" id="s-key-box" hidden><span id="s-key"></span><button class="btn tiny" data-copy="s-key">Copy</button></div>
|
||||
<label class="switch"><input type="checkbox" id="s-devfee"><span class="track"></span><span id="s-devfee-text">Dev fee 1% (1 block in 100)</span></label>
|
||||
<p class="note" id="s-devfee-note">The miner software's fee, the same way every GPU miner takes one. The protocol takes none. This switch turns it off.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">this machine</div>
|
||||
<div class="row">
|
||||
<input type="text" class="addr-input" id="s-name" placeholder="a name for this machine" maxlength="40" spellcheck="false">
|
||||
<button class="btn small" id="s-name-save">Rename</button>
|
||||
</div>
|
||||
<p class="note">A label for you only. Keys and labels come from the machine id <span class="mono" id="s-mid"></span>, never from the computer name.</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">cards</div>
|
||||
<div class="cards compact" id="s-cards"></div>
|
||||
<div class="row between"><p class="note">Switch a card on or off, set its identities. Only that card's worker restarts; the node keeps running.</p><button class="btn small" id="s-cards-save">Apply</button></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="switch"><input type="checkbox" id="s-vote"><span class="track"></span><span>Vote on finality checkpoints</span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-prove"><span class="track"></span><span>Prove assigned shards (proving v0; on a Mac the CPU prover is slow)</span></label>
|
||||
<label class="switch"><input type="checkbox" id="s-trust"><span class="track"></span><span>Trust proof records without verifying them (devnet only)</span></label>
|
||||
<p class="note" id="s-trust-note">Only when no verifier is found next to the engine: the node then includes proof records it never checked. Never on a testnet. A found verifier always wins. Changing this restarts the node.</p>
|
||||
<label class="switch"><input type="checkbox" id="s-login"><span class="track"></span><span>Start at login</span></label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">efficiency sweep</div>
|
||||
<label class="switch"><input type="checkbox" id="s-sweep"><span class="track"></span><span>Find each NVIDIA card's best MH per watt (once after install, then weekly)</span></label>
|
||||
<p class="note">On the live program, never restarting the worker: the cap steps from 100% of the card's default limit down to 50%, 15 s to settle and 60 s to measure per step, then holds the step with the most MH per watt. One administrator prompt per sweep. It stops at once if the card faults, a remote job takes the GPU, or the hour boundary is near. A cap you set with the slider is pinned: the sweep records, but leaves it. "Sweep now" on a card's tile runs one at any time; the table is in the log (SWEEP lines).</p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">version</div>
|
||||
<div class="row between"><span class="mono" id="s-version"></span><span class="row"><button class="btn small primary" id="s-install" hidden>Install now</button><button class="btn small" id="s-update">Check now</button></span></div>
|
||||
<label class="switch"><input type="checkbox" id="s-auto-update"><span class="track"></span><span>Install updates by itself at a safe moment</span></label>
|
||||
<p class="note" id="s-update-note"></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">remote jobs</div>
|
||||
<div class="row between"><label class="switch"><input type="checkbox" id="s-jobs-allow"><span class="track"></span><span>Allow remote jobs from Igneum (signed)</span></label><button class="btn small" id="s-jobs-check">Check now</button></div>
|
||||
<p class="note">Igneum publishes signed jobs (a benchmark, a script, a file to fetch, logs to collect, a restart) next to the update manifest. This machine runs each one once and reports to the Igneum log intake. Only jobs signed by the key below run; nothing else can send one.</p>
|
||||
<p class="note mono small" id="s-jobs-key"></p>
|
||||
<p class="note" id="s-jobs-note"></p>
|
||||
<div class="job-history" id="s-jobs-history"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<div class="k">folders</div>
|
||||
<p class="note mono small" id="s-node-dir"></p>
|
||||
<p class="note mono small" id="s-log-dir"></p>
|
||||
</div>
|
||||
<div class="field">
|
||||
<button class="btn small ghost" id="s-live" hidden>Open the live devnet page</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- the log drawer: chips filter by source, search filters by text, the ruler and the jump controls move in time.
|
||||
The list is virtualised (fixed row height, only the visible rows are in the DOM) so 20,000 lines scroll smoothly. -->
|
||||
<div class="drawer" id="drawer" aria-label="Logs">
|
||||
|
|
@ -357,6 +481,7 @@
|
|||
<button class="chip" id="log-wrap" title="Wrap long lines (up to 5,000 lines)">wrap</button>
|
||||
<button class="chip" id="log-copy" title="Copy the lines in view">copy</button>
|
||||
<label class="check small"><input type="checkbox" id="log-follow" checked><span>follow</span></label>
|
||||
<button class="chip" id="log-close" title="Close the log">close</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-ruler" id="log-ruler" title="Click to jump"><span class="t0 mono" id="log-ruler-0"></span><span class="t1 mono" id="log-ruler-1"></span><i class="win" id="log-ruler-win"></i></div>
|
||||
|
|
@ -367,18 +492,6 @@
|
|||
<button class="btn small log-jump" id="log-jump" hidden><svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 5v14M5 12l7 7 7-7"></path></svg><span id="log-jump-text">Newest</span></button>
|
||||
</div>
|
||||
|
||||
<footer class="bottom" id="bottom" hidden>
|
||||
<div class="left">
|
||||
<button class="btn small" id="btn-pause">Pause</button>
|
||||
<button class="btn small ghost" id="btn-logs" aria-expanded="false" aria-controls="drawer"><span id="btn-logs-text">Logs</span><svg class="chev" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 15 6-6 6 6"></path></svg></button>
|
||||
</div>
|
||||
<div class="mid mono" id="foot-status" aria-live="off"></div>
|
||||
<div class="right">
|
||||
<span class="mono dim" id="foot-version"></span>
|
||||
<button class="btn small ghost danger" id="btn-quit">Quit</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<div class="toast" id="toast" hidden></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js')
|
|||
const mod = { exports: {} };
|
||||
new Function('module', src)(mod);
|
||||
const N = mod.exports;
|
||||
const { updateNotice, jobNotice, clockNotice, gather, pick } = N;
|
||||
const { updateNotice, jobNotice, clockNotice, cardNotices, gather, pick } = N;
|
||||
|
||||
const NOW = 1_800_000_000;
|
||||
const upd = (over) => ({ available: true, version: '0.3.6', notes: '', checked_at: NOW - 60, error: '', status: 'ready', downloaded: true, ready: true, applying: false, progress: 1, size: 20_588_331, auto: true, wait: 'installs at the next safe moment', urgent: false, urgent_text: '', activation_height: 0, unsupported: false, min_supported: '', channel: 'devnet', published_at: '', file: '', updated_from: '', rolled_back: '', ...over });
|
||||
|
|
@ -139,3 +139,36 @@ test('clock: the engine words, Sync clock, the hint; gather() keeps it off the d
|
|||
assert.equal(pick(gather(st, { dashboard: false }), {}).kind, 'clock');
|
||||
assert.deepEqual(gather({}, { dashboard: true }), []);
|
||||
});
|
||||
|
||||
// hot-plug (src/hotplug.rs): the strip says what appeared or went, for 5 minutes, on every screen
|
||||
const cardOf = (over) => ({ index: 0, key: 'nvidia:0:NVIDIA GeForce RTX 5090', kind: 'discrete', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', enabled: true, state: 'mining', problem: '', reason: '', message: '', added_at: 0, removed_at: 0, gone: false, ...over });
|
||||
test('card notices: new card mining, new card not usable with the hint, removed card, nothing for the cards found at start', () => {
|
||||
const start = cardOf();
|
||||
assert.deepEqual(cardNotices([start], NOW), []);
|
||||
const added = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', added_at: NOW - 30, state: 'starting' });
|
||||
const [a] = cardNotices([start, added], NOW);
|
||||
assert.equal(a.kind, 'card-added'); assert.equal(a.level, N.LEVEL['job-done']); assert.equal(a.text, 'New card: gfx1201, mining.'); assert.equal(a.key, 'card:added:amd:1:gfx1201:' + (NOW - 30));
|
||||
const bad = cardOf({ key: 'amd::AMD Radeon RX 9070 XT', name: 'AMD Radeon RX 9070 XT', vendor: 'amd', enabled: false, state: 'unusable', problem: 'Code 43', message: 'not usable (Code 43)', reason: 'reboot with the card attached; if it persists, reinstall the driver with the card attached', added_at: NOW - 10 });
|
||||
const [b] = cardNotices([bad], NOW);
|
||||
assert.equal(b.text, 'AMD Radeon RX 9070 XT: not usable (Code 43). No worker runs on it.'); assert.equal(b.tone, 'warn'); assert.match(b.detail, /^reboot with the card attached/);
|
||||
const igpu = cardOf({ key: 'amd:0:gfx1036', name: 'gfx1036', vendor: 'amd', kind: 'integrated', enabled: false, state: 'off', added_at: NOW - 5 });
|
||||
assert.equal(cardNotices([igpu], NOW)[0].text, 'New card: gfx1036, off (integrated). Settings switches it on.');
|
||||
const removed = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', state: 'removed', removed_at: NOW - 60 });
|
||||
const [r] = cardNotices([removed], NOW);
|
||||
assert.equal(r.kind, 'card-removed'); assert.equal(r.text, 'Card removed: gfx1201. Its worker stopped.'); assert.equal(r.tone, 'warn');
|
||||
// the newest first; both go after CARD_S
|
||||
const two = cardNotices([added, removed], NOW);
|
||||
assert.equal(two[0].kind, 'card-added');
|
||||
assert.deepEqual(cardNotices([added, removed, bad], NOW + N.CARD_S + 1), []);
|
||||
});
|
||||
|
||||
test('card notices sit under a running job and show on the setup screens too', () => {
|
||||
const added = cardOf({ key: 'amd:1:gfx1201', name: 'gfx1201', vendor: 'amd', added_at: NOW - 30 });
|
||||
const withJob = s({ jobs: run(), mining: { cards: [added] } });
|
||||
assert.equal(pick(gather(withJob, { dashboard: true }), {}).kind, 'job-running');
|
||||
const quiet = s({ mining: { cards: [added] } });
|
||||
assert.equal(pick(gather(quiet, { dashboard: true }), {}).kind, 'card-added');
|
||||
assert.equal(pick(gather(quiet, {}), {}).kind, 'card-added');
|
||||
// closed: stays closed for that key; a later event on the same card has a new key
|
||||
assert.equal(pick(gather(quiet, { dashboard: true }), { ['card:added:amd:1:gfx1201:' + (NOW - 30)]: true }), null);
|
||||
});
|
||||
|
|
|
|||
182
app/igneum-app/ui/view.test.mjs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// node --test app/igneum-app/ui/view.test.mjs (no dependencies; CI runs it in the site job)
|
||||
// Loads the View block of app.js (plain browser JS: the file is run with `module` defined and no `document`, so only
|
||||
// the pure blocks execute) and checks the words each page shows for a given state (miner-ui-2).
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
const src = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'app.js'), 'utf8');
|
||||
const mod = { exports: {} };
|
||||
new Function('module', src)(mod);
|
||||
const V = mod.exports.View;
|
||||
|
||||
const card = (over) => ({ key: 'nvidia:0:RTX 5090', name: 'NVIDIA GeForce RTX 5090', vendor: 'nvidia', kind: 'discrete', worker: 'CUDA', vram_mb: 32768, enabled: true, state: 'mining', hash_now: 124.3, hash_avg: 120, accepted: 3, rejected: 0, identities: 8, ids: [], prepared: true, restart_in_s: 0, message: '', reason: '', power_w: 410.2, power_limit_w: 460, power_default_w: 575, power_pct: 80, power_applied: true, temp_gpu: 61, temp_mem: 72, telemetry_at: 1, ...over });
|
||||
|
||||
test('the six sections and their order', () => {
|
||||
assert.deepEqual(V.PAGES.map((p) => p.id), ['mine', 'prove', 'rewards', 'node', 'updates', 'settings']);
|
||||
assert.equal(V.page('node').title, 'Node');
|
||||
assert.equal(V.page('nonsense').id, 'mine');
|
||||
});
|
||||
|
||||
test('a GPU row: name, kind, the numbers where known, the switch', () => {
|
||||
const r = V.cardRow(card());
|
||||
assert.equal(r.name, 'NVIDIA GeForce RTX 5090');
|
||||
assert.equal(r.kindWord, 'Discrete');
|
||||
assert.equal(r.integrated, false);
|
||||
assert.equal(r.on, true);
|
||||
assert.equal(r.word, 'mining');
|
||||
assert.equal(r.tone, 'on');
|
||||
assert.equal(r.hash, '124');
|
||||
assert.equal(r.temp, '61 °C');
|
||||
assert.equal(r.tempTone, '');
|
||||
assert.equal(r.power, '410 W');
|
||||
assert.equal(r.cap, 460);
|
||||
assert.equal(r.meta, '32 GB · 3 blocks · next program ready');
|
||||
assert.equal(r.canToggle, true);
|
||||
});
|
||||
|
||||
test('a GPU row: temperatures turn amber then red; unknown numbers are empty', () => {
|
||||
assert.equal(V.cardRow(card({ temp_mem: 92 })).tempTone, 'warm');
|
||||
assert.equal(V.cardRow(card({ temp_mem: 96 })).tempTone, 'hot');
|
||||
assert.equal(V.cardRow(card({ temp_gpu: 93 })).tempTone, 'hot');
|
||||
const apple = V.cardRow(card({ key: 'apple::Apple M5 Max', name: 'Apple M5 Max', vendor: 'apple', kind: 'apple', worker: 'Metal', vram_mb: 131072, power_w: 0, power_default_w: 0, temp_gpu: 0, temp_mem: 0, telemetry_at: 0, hash_now: 7.4, accepted: 0, prepared: false }));
|
||||
assert.equal(apple.kindWord, 'Apple silicon');
|
||||
assert.equal(apple.hash, '7.4');
|
||||
assert.equal(apple.temp, '');
|
||||
assert.equal(apple.power, '');
|
||||
assert.equal(apple.cap, 0);
|
||||
assert.equal(apple.meta, '128 GB unified');
|
||||
});
|
||||
|
||||
test('an integrated GPU is shown as integrated and off, with its reason', () => {
|
||||
const r = V.cardRow(card({ key: 'intel:1:UHD', name: 'Intel UHD Graphics 770', vendor: 'other', kind: 'integrated', enabled: false, state: 'off', hash_now: 0, reason: 'integrated GPU: slow and shares the machine memory', power_w: 0, temp_gpu: 0 }));
|
||||
assert.equal(r.integrated, true);
|
||||
assert.equal(r.kindWord, 'Integrated');
|
||||
assert.equal(r.on, false);
|
||||
assert.equal(r.word, 'off');
|
||||
assert.equal(r.tone, 'off');
|
||||
assert.equal(r.hash, '');
|
||||
assert.equal(r.sub, 'integrated GPU: slow and shares the machine memory');
|
||||
assert.equal(V.cardRow(card({ kind: 'unknown' })).canToggle, false);
|
||||
assert.equal(V.cardRow(card({ state: 'restarting', restart_in_s: 7 })).word, 'restart in 7 s');
|
||||
assert.equal(V.cardRow(card({ state: 'faulted' })).tone, 'bad');
|
||||
assert.equal(V.cardRow(card({ state: 'waiting' })).word, 'waiting for the node');
|
||||
});
|
||||
|
||||
test('the big button: start when paused, stop when mining, disabled with no card on', () => {
|
||||
const m = (over) => ({ state: 'mining', paused: false, cards: [card()], ...over });
|
||||
assert.deepEqual(V.toggle(m(), { synced: true }, {}), { label: 'Stop mining', sub: 'mining on 1 of 1 card', cls: 'stop', disabled: false, act: 'pause' });
|
||||
assert.deepEqual(V.toggle(m({ state: 'paused', paused: true }), { synced: true }, {}), { label: 'Start mining', sub: 'paused · the node keeps running', cls: '', disabled: false, act: 'resume' });
|
||||
const none = V.toggle(m({ cards: [card({ enabled: false })] }), { synced: true }, {});
|
||||
assert.equal(none.disabled, true);
|
||||
assert.equal(none.act, '');
|
||||
assert.equal(none.sub, 'switch a GPU on below first');
|
||||
assert.equal(V.toggle(m({ cards: [] }), { synced: true }, {}).sub, 'no GPU this app can drive');
|
||||
assert.equal(V.toggle(m({ state: 'waiting' }), { synced: false }, {}).sub, 'waiting for the node to sync');
|
||||
assert.equal(V.toggle(m(), { synced: true }, { quitting: true }).disabled, true);
|
||||
});
|
||||
|
||||
test('the node words: synced, syncing with a percentage, failed, a blocked clock', () => {
|
||||
const n = (over) => ({ state: 'synced', blocks: 135200, headers: 135200, peers: 3, daa: 140000, last_reading_age_s: 4, message: '', restart_in_s: 0, ...over });
|
||||
const ok = V.nodeWords(n(), { severity: 'none' }, '');
|
||||
assert.equal(ok.word, 'synced');
|
||||
assert.equal(ok.tone, 'ok');
|
||||
assert.match(ok.line, /every block/);
|
||||
const sy = V.nodeWords(n({ state: 'syncing', blocks: 50000, headers: 100000 }), { severity: 'none' }, 'about 3 min left at 300 blocks/s');
|
||||
assert.equal(sy.word, 'syncing');
|
||||
assert.equal(sy.line, 'about 3 min left at 300 blocks/s · 50,000 of 100,000 (50%)');
|
||||
assert.equal(V.nodeWords(n({ state: 'failed', message: 'igneumd could not start' }), { severity: 'none' }, '').tone, 'bad');
|
||||
const blocked = V.nodeWords(n(), { severity: 'block', skew_s: -75 }, '');
|
||||
assert.equal(blocked.tone, 'bad');
|
||||
assert.match(blocked.line, /clock is off by 75 s/);
|
||||
assert.equal(V.peersLine(n({ peers: 0 })), 'none yet: looking for the seed node');
|
||||
assert.equal(V.peersLine(n({ peers: 1 })), 'one other node this one talks to');
|
||||
assert.equal(V.heightLine(n({ state: 'syncing', blocks: 10, headers: 500 })), 'of 500 headers seen');
|
||||
assert.equal(V.heightLine(n()), 'blocks this node holds');
|
||||
});
|
||||
|
||||
test('the next consensus switch is the first height above the DAA score, in plain words', () => {
|
||||
const sw = [
|
||||
{ key: 'fees_v1_activation_daa', name: 'Fees v1', daa: 210000 },
|
||||
{ key: 'difficulty_v2_activation_daa', name: 'Difficulty v2', daa: 33000 },
|
||||
{ key: 'finality_v3_activation_daa', name: 'Finality v3', daa: 135200 }
|
||||
];
|
||||
const next = V.nextSwitch(sw, 140000);
|
||||
assert.equal(next.name, 'Fees v1');
|
||||
assert.equal(next.away, 70000);
|
||||
assert.equal(V.switchLine(next, 140000), 'Fees v1 at DAA 210,000: 70,000 blocks away, about 19 h 27 min at one block a second.');
|
||||
assert.equal(V.nextSwitch(sw, 20000).name, 'Difficulty v2');
|
||||
assert.equal(V.nextSwitch(sw, 300000), null);
|
||||
assert.match(V.switchLine(null, 300000), /Every planned switch is behind this node/);
|
||||
assert.match(V.switchLine(null, 0), /^A switch is a planned rule change/);
|
||||
assert.equal(V.nextSwitch([], 5), null);
|
||||
});
|
||||
|
||||
test('the prove words follow the switch, the setup, the node and the status', () => {
|
||||
const pv = (over) => ({ enabled: true, available: true, setup_hint: '', backend: 'cuda', status: 'idle', message: '', current: '', verifier_mode: 'command', pool_entries: 4, pool_verified: 4, pool_failed: 0, ...over });
|
||||
assert.equal(V.proveWords(pv(), false, true).word, 'off');
|
||||
assert.equal(V.proveWords(pv({ status: 'setup', available: false, setup_hint: 'proving needs the WSL2 setup' }), true, true).word, 'needs setup');
|
||||
assert.equal(V.proveWords(pv(), true, false).word, 'waiting');
|
||||
assert.equal(V.proveWords(pv({ status: 'proving', current: 'block 59199 shard 0' }), true, true).sub, 'block 59199 shard 0');
|
||||
assert.equal(V.proveWords(pv({ status: 'submitted' }), true, true).tone, 'on');
|
||||
assert.equal(V.proveWords(pv(), true, true).word, 'idle');
|
||||
assert.equal(V.verifierWords(pv()).word, 'verifying · pool 4/4');
|
||||
assert.equal(V.verifierWords(pv()).tone, 'ok');
|
||||
assert.equal(V.verifierWords(pv({ verifier_mode: 'off', pool_entries: 0 })).tone, 'warn');
|
||||
assert.equal(V.verifierWords(pv({ verifier_mode: 'off', verifier_reason: 'the WSL2 prover is not installed' })).needsSetup, true);
|
||||
assert.equal(V.verifierWords({}).word, 'not read yet');
|
||||
});
|
||||
|
||||
test('the dev-fee lines name the share and where Settings turns it off', () => {
|
||||
const s = (on, fee) => ({ settings: { dev_fee: on }, mining: { fee_total: fee }, dev_fee: { on, percent: on ? 1 : 0, address: '0x1234567890abcdef1234567890abcdef12345678', line: '' } });
|
||||
assert.equal(V.devFeeLine(s(true, 12)), 'One block in 100 pays the miner software’s dev fee (12 so far). Settings turns it off.');
|
||||
assert.equal(V.devFeeLine(s(false, 0)), 'The dev fee is off. Every block pays this address.');
|
||||
assert.equal(V.devFeeText(s(true, 0)), 'Dev fee 1% (1 block in 100) to 0x123456…5678');
|
||||
assert.match(V.devFeeText(s(false, 0)), /^Dev fee off/);
|
||||
});
|
||||
|
||||
test('the remote-jobs line and the helpers', () => {
|
||||
const title = (j) => j.title || j.kind;
|
||||
assert.equal(V.jobsNote({ allowed: false }, 1000, title), 'Off: nothing runs here until Settings allows remote jobs.');
|
||||
assert.equal(V.jobsNote({ allowed: true, url_set: false }, 1000, title), 'No jobs address in this build.');
|
||||
assert.equal(V.jobsNote({ allowed: true, url_set: true, active: true, id: 'job-3', kind: 'build', title: 'build' }, 1000, title), 'Running build (job-3).');
|
||||
assert.equal(V.jobsNote({ allowed: true, url_set: true, checked_at: 940, queued: 2 }, 1000, title), 'Nothing running; checked 1 min ago; 2 queued.');
|
||||
assert.equal(V.shortHex('0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a'), '0x2b1a81…79ef7a');
|
||||
assert.equal(V.shortHex('0xabc'), '0xabc');
|
||||
assert.equal(V.withCommas(1234567), '1,234,567');
|
||||
assert.equal(V.compact(2500000), '2.50M');
|
||||
assert.equal(V.rel(90), '1 min ago');
|
||||
});
|
||||
|
||||
test('hot-plug (src/hotplug.rs): a removed card and a faulty card are shown as such, with no switch, and leave the counts', () => {
|
||||
const gone = card({ key: 'amd:gfx1201', name: 'gfx1201', vendor: 'amd', state: 'removed', removed_at: 1000, added_at: 0, gone: false, hash_now: 0 });
|
||||
const r = V.cardRow(gone);
|
||||
assert.equal(r.removed, true);
|
||||
assert.equal(r.on, false);
|
||||
assert.equal(r.canToggle, false);
|
||||
assert.equal(r.word, 'removed');
|
||||
assert.equal(r.tone, 'off');
|
||||
assert.equal(r.hash, '');
|
||||
assert.match(r.sub, /^unplugged; its worker stopped/);
|
||||
const bad = card({ key: 'amd:gfx1201#2', name: 'AMD Radeon RX 9070 XT', code: 'gfx1201', vendor: 'amd', enabled: false, state: 'unusable', problem: 'Code 43', message: 'not usable (Code 43)', reason: 'reboot with the card attached; if it persists, reinstall the driver with the card attached', added_at: 900, removed_at: 0, device: '1', platform: 'AMD Accelerated Parallel Processing', bus: '0000:03:00.0' });
|
||||
const b = V.cardRow(bad);
|
||||
assert.equal(b.unusable, true);
|
||||
assert.equal(b.canToggle, false);
|
||||
assert.equal(b.word, 'not usable (Code 43)');
|
||||
assert.equal(b.tone, 'bad');
|
||||
assert.match(b.sub, /^reboot with the card attached/);
|
||||
assert.equal(b.title, 'gfx1201 · CUDA device 1 · AMD Accelerated Parallel Processing · bus 0000:03:00.0');
|
||||
assert.equal(V.cardRow(card()).title, 'CUDA device undefined'.replace(' device undefined', '') === '' ? '' : V.cardRow(card()).title);
|
||||
// a row that is gone (five minutes after removal) is not shown at all; the big button counts only present cards
|
||||
assert.deepEqual(V.shownCards([card(), card({ key: 'x', gone: true })]).map((c) => c.key), ['nvidia:0:RTX 5090']);
|
||||
assert.equal(V.present(card()), true);
|
||||
assert.equal(V.present(gone), false);
|
||||
assert.equal(V.present(bad), false);
|
||||
const t = V.toggle({ state: 'mining', paused: false, cards: [gone, bad] }, { synced: true }, {});
|
||||
assert.equal(t.disabled, true);
|
||||
assert.equal(t.sub, 'no GPU this app can drive');
|
||||
const t2 = V.toggle({ state: 'mining', paused: false, cards: [card(), gone] }, { synced: true }, {});
|
||||
assert.equal(t2.sub, 'mining on 1 of 1 card');
|
||||
});
|
||||
|
|
@ -116,7 +116,7 @@ final class App: NSObject, NSApplicationDelegate, WKNavigationDelegate, WKUIDele
|
|||
window.titleVisibility = .hidden
|
||||
window.isMovableByWindowBackground = true
|
||||
window.backgroundColor = obsidian
|
||||
window.minSize = NSSize(width: 900, height: 620)
|
||||
window.minSize = NSSize(width: 900, height: 600)
|
||||
window.center()
|
||||
window.delegate = self
|
||||
window.isReleasedWhenClosed = false
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
#endif
|
||||
#include <windows.h>
|
||||
#include <shellapi.h>
|
||||
#include <dbt.h>
|
||||
#include <wrl.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
|
@ -284,6 +285,9 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
|||
case WM_SIZE:
|
||||
if (g_controller) { RECT rc; GetClientRect(hwnd, &rc); g_controller->put_Bounds(rc); }
|
||||
return 0;
|
||||
case WM_GETMINMAXINFO: // the dashboard lays out from 900 x 600 up (app/igneum-app/ui); the Mac window says the same
|
||||
((MINMAXINFO*)lp)->ptMinTrackSize.x = 900; ((MINMAXINFO*)lp)->ptMinTrackSize.y = 600;
|
||||
return 0;
|
||||
case WM_ENGINE_LINE: {
|
||||
if (wp == 1) {
|
||||
g_exited = true;
|
||||
|
|
@ -324,6 +328,11 @@ static LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
|
|||
}
|
||||
}
|
||||
return 0;
|
||||
case WM_DEVICECHANGE:
|
||||
// a device arrived or left (an eGPU through a USB4 box, a driver coming up or crashing): the engine enumerates
|
||||
// the cards now instead of at its next minute poll (src/hotplug.rs); DBT_DEVNODES_CHANGED needs no registration
|
||||
if (wp == DBT_DEVNODES_CHANGED || wp == DBT_DEVICEARRIVAL || wp == DBT_DEVICEREMOVECOMPLETE) sendEngine("detect");
|
||||
return TRUE;
|
||||
case WM_TRAY:
|
||||
if (lp == WM_LBUTTONUP || lp == WM_LBUTTONDBLCLK) { ShowWindow(hwnd, SW_SHOW); SetForegroundWindow(hwnd); }
|
||||
else if (lp == WM_RBUTTONUP || lp == WM_CONTEXTMENU) showTrayMenu();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,6 @@
|
|||
// packaging/windows/Igneum-Miner.iss when the app version moves. Include guards, not #pragma once: rc.exe reads it too.
|
||||
#ifndef IGNEUM_HOST_VERSION_H
|
||||
#define IGNEUM_HOST_VERSION_H
|
||||
#define IGNEUM_HOST_VERSION_STR "0.3.9"
|
||||
#define IGNEUM_HOST_VERSION_RC 0,3,9,0
|
||||
#define IGNEUM_HOST_VERSION_STR "0.3.10"
|
||||
#define IGNEUM_HOST_VERSION_RC 0,3,10,0
|
||||
#endif
|
||||
|
|
|
|||
227
docs/api/public-stats.md
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
# Public stats API
|
||||
|
||||
5 October 2026. Two JSON endpoints on the site for profitability sites, pool software and anyone who wants the
|
||||
network numbers without running a node: `/api/stats` and `/api/supply`. WhatToMine's listing form asks for an
|
||||
explorer or pool with an API, the reward halving schedule and a source to fetch total coins from; this is that source.
|
||||
A third endpoint, `/api/explorer`, feeds the explorer pages (docs/plans/explorer.md).
|
||||
|
||||
Both are served by Vercel functions (`site/api/stats.mjs`, `site/api/supply.mjs`) that read what the devnet observer
|
||||
(`tools/observer/observer.mjs`) wrote to Neon; no secret is involved beyond the database connection the site already
|
||||
holds. Cached 10 s at the edge (`Cache-Control: public, max-age=10, s-maxage=10`), CORS open (`Access-Control-Allow-Origin: *`),
|
||||
GET only. A failed read answers 500 with `{ok: false, error}` and `Cache-Control: no-store`.
|
||||
|
||||
The contract is the `FIELDS` list exported by each handler. `site/api/public-stats.test.mjs` checks a fixture against
|
||||
it without a database, and `tools/ci/public-api-check.mjs <url>` checks a deployment (CI runs it against
|
||||
https://igneum.network on master).
|
||||
|
||||
## /api/stats
|
||||
|
||||
| Field | Meaning | Source |
|
||||
|---|---|---|
|
||||
| `network`, `chain_id`, `node_version` | Network name, EVM chain id (4461 mainnet, 4462 testnet, 4463 devnet, design 8.1), the node's version | observer `live_state` |
|
||||
| `algorithm` | The lottery hash, named | fixed text |
|
||||
| `stale`, `age_s`, `observer_updated_at` | `stale` when the observer has not written for 30 s; treat every number as last known then | observer |
|
||||
| `height` | The chain block number (the EVM block number): the number of the newest chain block the observer has a shard plan for | `live_blocks.number` |
|
||||
| `block_count`, `header_count` | Every DAG block the node holds | `getBlockDagInfo` |
|
||||
| `daa`, `blue_score` | DAA score and blue score of the newest block | `live_blocks`, `getSinkBlueScore` |
|
||||
| `difficulty` | The node's difficulty (target per block) | `getBlockDagInfo` |
|
||||
| `hashrate`, `hashrate_unit`, `hashrate_source` | H/s: the node's `estimateNetworkHashesPerSecond` over 1,000 blocks, else blue work added per second over 10 min | observer |
|
||||
| `block_time_target_s`, `block_time_measured_s` | 1 s by design (spec 2.1); 60 / DAG blocks in the last 60 s | observer |
|
||||
| `blocks_per_day_target`, `blocks_per_day_measured` | 86,400; the last hour's DAG blocks x 24 (null until the observer has an hour) | observer |
|
||||
| `block_reward` | `E(daa)` of spec 2.5 at the newest DAA score: `sompi` (8 decimals), `ign`, the 80% `miner_ign` and 20% `proving_pool_ign`, `ramp_factor`, `halving_period`, `next_halving_daa`, `next_halving_in_s` | `site/lib/emission.mjs` |
|
||||
| `last_block` | Hash, time, age, blue score, DAA, coinbase address, vote key id, EVM transaction count, whether it is a chain block | `live_blocks` |
|
||||
| `finality` | Finality v2: active, the latest locked checkpoint index and blue score | observer |
|
||||
| `peers`, `mempool`, `miners_10m` | Connected peers, mempool size, distinct vote keys in 10 min (a card runs several) | observer |
|
||||
|
||||
Note for a profitability calculator: `block_reward` is per blue block merged; at the 1 block per second target that is
|
||||
also the reward per DAA second. The 20% proving-pool part is paid to provers, not to the miner of the block, so a
|
||||
miner's expected income per block is `miner_ign`. During the 30-day launch ramp the reward climbs from 10% to 100%
|
||||
of the schedule (`ramp_factor`); `/api/supply` shows where the ramp stands.
|
||||
|
||||
Example, the devnet on 5 October 2026 (through the local preview against a test observer, so `block_time_measured_s`
|
||||
reflects a one-minute window):
|
||||
|
||||
```
|
||||
{
|
||||
"ok": true,
|
||||
"now": "2026-10-05T19:32:09.321Z",
|
||||
"network": "igneum-devnet",
|
||||
"chain_id": 4463,
|
||||
"node_version": "2.1.0",
|
||||
"algorithm": "Igneum lottery hash: random-program GPU hash, new program every hour, generator v2 (docs/spec/01-lottery-hash.md)",
|
||||
"stale": false,
|
||||
"age_s": 0.9,
|
||||
"height": 82145,
|
||||
"block_count": 126358,
|
||||
"header_count": 126358,
|
||||
"daa": 126357,
|
||||
"blue_score": 123504,
|
||||
"difficulty": 125543017.00697394,
|
||||
"hashrate": 258756880,
|
||||
"hashrate_unit": "H/s",
|
||||
"hashrate_source": "the node's estimateNetworkHashesPerSecond over a 1,000-block window; blue work added per second over 10 min when the node refuses the window",
|
||||
"block_time_target_s": 1,
|
||||
"block_time_measured_s": 0.952,
|
||||
"blocks_per_day_target": 86400,
|
||||
"blocks_per_day_measured": 14040,
|
||||
"block_reward": {
|
||||
"sompi": "455909062",
|
||||
"ign": "4.55909062",
|
||||
"miner_ign": "3.6472725",
|
||||
"proving_pool_ign": "0.91181812",
|
||||
"split": "80% block producer, 20% proving pool",
|
||||
"daa_used": 126357,
|
||||
"ramp_factor": 0.143874,
|
||||
"halving_period": 0,
|
||||
"next_halving_daa": 63115200,
|
||||
"next_halving_in_s": 62988843
|
||||
},
|
||||
"last_block": {
|
||||
"hash": "bbec3139d3f38e3517716374707998e77f536a67813edfb619c5bed93a5779ab",
|
||||
"time": "2026-10-05T19:32:06.157Z",
|
||||
"ts_ms": 1791228726157,
|
||||
"age_s": 3.2,
|
||||
"blue_score": 123504,
|
||||
"daa": 126357,
|
||||
"miner": "igneumdev:qrt8nzgrghr2a2xuc7lstzclcnt3n632d2chhc946rphkc6flcyswvkrym8s4",
|
||||
"miner_id": "7b8ef6fd",
|
||||
"tx_count": 0,
|
||||
"chain": true
|
||||
},
|
||||
"finality": {
|
||||
"active": true,
|
||||
"latest_locked_index": 4116,
|
||||
"latest_locked_blue_score": 123480,
|
||||
"chain_id": "igneum-devnet"
|
||||
},
|
||||
"peers": 4,
|
||||
"mempool": 0,
|
||||
"miners_10m": 21,
|
||||
"observer_updated_at": "2026-10-05T19:32:08.394726+00:00",
|
||||
"source": "tools/observer reading one node every 2 s; reward from docs/spec/02-consensus.md 2.5 at the node's DAA score"
|
||||
}
|
||||
```
|
||||
|
||||
## /api/supply
|
||||
|
||||
| Field | Meaning |
|
||||
|---|---|
|
||||
| `unit` | IGN; the coinbase pays in 8-decimal units (open item O-2.6), the EVM shows 18 |
|
||||
| `daa` | The newest DAA score the observer stored |
|
||||
| `max_supply_ign` | 4,000,000,000, the hard cap (spec 2.5, no tail emission: spec 5.10) |
|
||||
| `circulating_ign`, `circulating_sompi` | Minted so far by the rule: `E(t)` summed over every DAA second from 0 to `daa`, exact (floor sum, `mintedByRule`) |
|
||||
| `minted_at_end_ign`, `never_minted_ign` | What the schedule reaches when the per-second rate hits 0 (period 32), and the part of the cap the ramp and the floors never mint |
|
||||
| `emission_per_second_ign`, `block_reward_ign` | `E(daa)` now |
|
||||
| `halving` | Interval 63,115,200 DAA s (two years), current period, the next halving's DAA score, seconds to it, a date estimate at one DAA second per second |
|
||||
| `ramp` | 10% at genesis to 100% at DAA 2,592,000 (30 days), the factor now, whether it is complete |
|
||||
| `schedule` | 33 rows: period, start and end DAA, years from genesis, IGN per second, IGN per block at 1 BPS, minted by the end of the period, share of the cap |
|
||||
| `check` | The observer's hourly comparison of the chain against the rule over its newest 500 blocks: `rule_match` counts blocks whose coinbase payload declares exactly `E(daa)`; `sum_match` counts blocks whose coinbase outputs equal the declared subsidies of the blocks they merge; `examples` names mismatches |
|
||||
| `rule`, `source`, `note` | The formula, where it lives, and the caveat: the chain pays per block merged, so a block rate above target mints above the schedule for as long as it lasts |
|
||||
|
||||
Example (schedule cut to five rows here):
|
||||
|
||||
```
|
||||
{
|
||||
"ok": true,
|
||||
"now": "2026-10-05T19:32:09.374Z",
|
||||
"network": "igneum-devnet",
|
||||
"chain_id": 4463,
|
||||
"unit": {
|
||||
"symbol": "IGN",
|
||||
"decimals_consensus": 8,
|
||||
"decimals_evm": 18,
|
||||
"note": "the coinbase pays in 8-decimal units (open item O-2.6 keeps Kaspa's SOMPI_PER_KASPA); the EVM shows the same amount at 18 decimals"
|
||||
},
|
||||
"daa": 126357,
|
||||
"max_supply_ign": "4000000000",
|
||||
"circulating_ign": "488236.39686436",
|
||||
"circulating_sompi": "48823639686436",
|
||||
"minted_at_end_ign": "3963038988.86765648",
|
||||
"never_minted_ign": "36961011.13234352",
|
||||
"emission_per_second_ign": "4.55909062",
|
||||
"block_reward_ign": "4.55909062",
|
||||
"halving": {
|
||||
"interval_daa_s": 63115200,
|
||||
"interval_years": 2,
|
||||
"period": 0,
|
||||
"next_halving_daa": 63115200,
|
||||
"next_halving_in_s": 62988843,
|
||||
"next_halving_estimate": "2028-10-03T20:26:12.374Z",
|
||||
"estimate_note": "the estimate assumes one DAA second per wall-clock second from now"
|
||||
},
|
||||
"ramp": {
|
||||
"start_percent": 10,
|
||||
"length_daa_s": 2592000,
|
||||
"length_days": 30,
|
||||
"factor_now": 0.143874,
|
||||
"complete": false,
|
||||
"remaining_s": 2465643,
|
||||
"withheld_ign": "36961011.13234352",
|
||||
"withheld_note": "the ramp withholds about 37 million IGN that are never minted; integer floors withhold the rest (spec 2.5)"
|
||||
},
|
||||
"schedule": [
|
||||
{
|
||||
"period": 0,
|
||||
"start_daa": 0,
|
||||
"end_daa": 63115200,
|
||||
"years_from_genesis": "0 to 2",
|
||||
"per_second_ign": "31.68808781",
|
||||
"per_block_ign_at_1bps": "31.68808781",
|
||||
"minted_by_end_ign": "1963038999.85152848",
|
||||
"share_of_cap_by_end": 49.0759
|
||||
},
|
||||
{
|
||||
"period": 1,
|
||||
"start_daa": 63115200,
|
||||
"end_daa": 126230400,
|
||||
"years_from_genesis": "2 to 4",
|
||||
"per_second_ign": "15.8440439",
|
||||
"per_block_ign_at_1bps": "15.8440439",
|
||||
"minted_by_end_ign": "2963038999.40880848",
|
||||
"share_of_cap_by_end": 74.0759
|
||||
},
|
||||
{
|
||||
"period": 2,
|
||||
"start_daa": 126230400,
|
||||
"end_daa": 189345600,
|
||||
"years_from_genesis": "4 to 6",
|
||||
"per_second_ign": "7.92202195",
|
||||
"per_block_ign_at_1bps": "7.92202195",
|
||||
"minted_by_end_ign": "3463038999.18744848",
|
||||
"share_of_cap_by_end": 86.5759
|
||||
},
|
||||
"... 29 more rows ...",
|
||||
{
|
||||
"period": 32,
|
||||
"start_daa": 2019686400,
|
||||
"end_daa": 2082801600,
|
||||
"years_from_genesis": "64 to 66",
|
||||
"per_second_ign": "0",
|
||||
"per_block_ign_at_1bps": "0",
|
||||
"minted_by_end_ign": "3963038988.86765648",
|
||||
"share_of_cap_by_end": 99.0759
|
||||
}
|
||||
],
|
||||
"check": {
|
||||
"bps": 1,
|
||||
"sampled": 470,
|
||||
"examples": [],
|
||||
"sum_match": 466,
|
||||
"checked_at": "2026-10-05T19:30:28.385Z",
|
||||
"rule_match": 470,
|
||||
"sum_skipped": 4,
|
||||
"sum_mismatch": 0,
|
||||
"rule_mismatch": 0
|
||||
},
|
||||
"rule": "E(t) = ramp(t) * floor(10^9 * UNIT / 31,557,600) >> floor(t / 63,115,200); ramp(t) = min(1, 1/10 + 9/10 * t / 2,592,000); t = DAA score / bps, bps = 1",
|
||||
"source": "docs/spec/02-consensus.md 2.5; vendor/igneum-node consensus/core/src/igneum.rs block_subsidy and launch_ramp; circulating = the rule summed over every DAA second from 0 to the node's DAA score (site/lib/emission.mjs mintedByRule, exact)",
|
||||
"note": "circulating is the schedule at this DAA score. The chain pays E per blue block it merges and per red inside the DAA window, which tracks the schedule one block per DAA step; the devnet of 3 October 2026 ran 4.7x the schedule for eight minutes during a retarget lag (spec 2.5). check reports what the observer measured on the newest blocks."
|
||||
}
|
||||
```
|
||||
|
||||
## /api/explorer
|
||||
|
||||
The explorer's own feed, cached 5 s: `?blocks=N[&before=ms]` (latest blocks), `?block=hash`, `?height=N`,
|
||||
`?address=0x..|igneumdev:..`, `?search=q`. Shapes are in `site/api/explorer.mjs`; the pages are the reference client.
|
||||
Balances need `EXPLORER_EVM_RPC` on the deployment (a public EVM JSON-RPC); without it `balance.available` is false
|
||||
with the reason.
|
||||
|
|
@ -1451,6 +1451,32 @@ The adopted fee table (spec 05 section 5.11) reaches the devnet by `fees_v1_acti
|
|||
|
||||
Block rate for H: DAA 111,230 at 15:23Z, 112,227 at 15:40:13Z, 0.965 blocks/s; H = 210,000 is 24 h ahead of a publish before about 19:50Z on 5 October (the runbook moves it otherwise).
|
||||
|
||||
## 5 October 2026 (night), the C4 fix: certificate-driven reorg
|
||||
|
||||
Owner: the consensus engineer and cryptographer agent, worktrees `igneum-wt-c4` (branch `c4-fix`) and `vendor/igneum-node-c4` (fork branch `c4-fix` on release-0.3.6 a24ab01a). Harness `tools/finality-attacks/c4.mjs` on the fast-time 3-node network (100-ms proxied links), node built on the Mac in `vendor/igneum-node/target-c4` from the fork worktree (an APFS clone of `target-036`), suites on PC 2 through `tools/build-job.mjs`. The Mac carried two other builds and the M20 live sync throughout; every figure is a count, an index or a second from the harness clock.
|
||||
|
||||
**The cause, in the code.** `processes/finality.rs`: `ingest_certificate` verified a certificate only when its block was the node's own determination at that index (`cp.hash == cert.checkpoint`); any other block went to `hold_pending`, and nothing ever tried the pending certificate against the table at its own block. `fork_choice_lock` reads `state.locks`, which only `evaluate` filled, and `evaluate` only ever ran over the node's own determination. So a certified checkpoint off the node's chain never became a lock and never constrained the sink search, whatever spec 3.5 says. Second cause, found tonight on the harness: `protocol/flows/src/v10/blockrelay/flow.rs` skips a relayed block whose blue work is under the virtual's merge-depth root ("hence we are skipping it"), and the certified chain is lighter by construction, so the node on the heavier side never received the certified chain's blocks at all: in the first runs on the fixed consensus n0 held B's certificates by gossip for the whole heal window and B's blocks never arrived (n0's log shows only its own blocks "via submit block" after the reconnect).
|
||||
|
||||
**The fix.** Fork: `ingest_off_chain` (verifies against `voters_at` of the certificate's own block, Q3 and Q5 by `quorum_at` from that block's past, the lock chain by `off_lock_chain`, then LOCKED with a `FinalityLock` notification and a `VirtualStateProcessingMessage::Resolve` nudge so the sink moves without waiting for a block); `retry_pending_off_chain` on every virtual change; the lock-chain guard in `evaluate` (a determination off the chain through the node's nearest locks never locks and never aggregates); `fork_choice_lock` reports a lock beyond the depth-based finality point once; `wants_unknown_certified_block` and the relay-flow bypass of the merge-depth skip while a pending certificate names a block the node lacks (`finality_wants_blocks` through `ConsensusApi` and the session). Not gated on `finality_v3_activation_daa`: rule v2 took the same pending path.
|
||||
|
||||
**Unit tests (PC 2, job build-20261005-180827, 18:09 UTC): `kaspa-consensus` 97 passed, 0 failed, 3 ignored; `kaspa-consensus-core` 101 passed.** New: `a_lighter_certified_chain_wins_and_a_heavier_uncertified_one_does_not_override_it` (main chain 77 blocks locks to 13, a 7-block side chain's index-14 certificate is adopted, the sink moves to the side tip with no new block, ten more main-chain blocks do not move it back, a second certificate at 14 over the main block is CONFLICTING and the lock stands, the side chain then locks 15), `the_certificate_driven_reorg_holds_under_rule_v2` (the same at `finality_v3_activation_daa` never), `a_chain_that_misses_an_adopted_lock_never_locks_here` (the evaluate guard and the off-lock conflict). `reorg_past_an_unlocked_checkpoint_re_determines_it_and_verifies_the_pending_certificate` rewritten for the new behaviour (pending while the block is unknown, adopted when it arrives). `kaspa-p2p-flows` lib tests do not compile on release-0.3.6 before or after this change (nine `epoch_seed_headers` errors in the pruning-proof message tests; the M20 job build-20261005-172340 hit the same nine an hour earlier). Six PC 2 jobs were lost tonight to two tooling faults, both fixed in the class: `igneum-ota-sign embedded | head -1` under `pipefail` (SIGPIPE panic, four scripts, `tools/ci/signer-pipe-check.sh`) and the one shared `build-inputs.zip` in the downloads folder (a job published while another agent's pack landed pinned that agent's sources, three times; `build-job.mjs` now names every job's zip).
|
||||
|
||||
**Harness, weight against work (B four keys and 70% of the weight, A two keys and 30%; at the cut A mines 0.6 and B 0.4 blocks/s; `WINDOW` = weight window, ban and `min_daa` at fast time).** The 120-DAA window of the earlier runs turns every long split into F21's partition-longer-than-a-window shape once the p2p reconnect is added: n0 dials the proxy again on the connection manager's backoff, 84 to 114 s after the heal in every run tonight (the original sweep's 6 s was a short cut), so A's chain is 130 + 84 s = 128 DAA past the cut before any certificate can reach it, past its 120-DAA frozen table (v3) or its own two-thirds share of a sliding table (v2, 126 DAA at W 240 and s 0.3), and A locks alone first. With `WINDOW=240` and `WARM=320` the bound is 400 s (v3) or 210 s (v2) after the cut.
|
||||
|
||||
| Run | Node | Rule, W, split | B locks during the split | n0 reconnected | n0 adopted off-chain | Final chain | Conflicting | Disagreeing | Verdict |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| on 90 s (the sweep's framing) | c4 consensus fix, no sync hook | v3, 120, 90 s | 0 (36 blue blocks for B, a new index needs 50) | 6 s | 0 | A, all three (no certificate to follow) | 0 | 0 | not the C4 shape |
|
||||
| v2 90 s | same | v2, 120, 90 s | 1 (index 8) | 6 s | 0 | apart | 5 / 5 / 5 | 2 | n0 locked 10 alone at 18:32:42, B's certificate for 8 reached it at 18:32:43: F21's bound (63 DAA of A's own chain) crossed before the heal |
|
||||
| on 130 s | same | v3, 120, 130 s | 1 (index 9) | 84 s | 0 | apart | 9 / 3 / 3 | 2 | n0 locked 12 alone at DAA 359, one window after lock 8 at 239, 6 s before the reconnect |
|
||||
| off 150 s (control) | same | no certificate, 150 s | 0 | 96 s | 0 | A (heavier), all three; B's nodes re-determined 2 indices | 0 | 0 | PASS, as in the sweep |
|
||||
| on 130 s, W 240 | same | v3, 240, 130 s | 2 (10, 11) | 114 s | 0 (certificates 13 and 14 pending, blocks unknown) | apart | 0 | 0 | the sync gap: n0 never received a B block |
|
||||
| v2 130 s, W 240 | same | v2, 240, 130 s | 2 (10, 11) | 114 s | 0 | apart, n0 locked 16 alone at 293 s | 0 / 1 / 1 | 0 | the sync gap again (n0 reconnected after v2's 210-s bound) |
|
||||
| v2 130 s, W 240 | c4 fix with the sync hook | v2, 240, 130 s | 1 (index 12) | 84 s | 3 (12, 13, 14 within 2 s of the first B block; 11 re-determined) | B, all three, A's split tip abandoned | 0 | 0 | PASS |
|
||||
| on 130 s, W 240 | same | v3, 240, 130 s | 0 (Poisson: 52 blue blocks, the index fell just short) | 84 s | n1 1, n2 2 (B's nodes adopted A's post-heal certificates and moved before IBD) | A, all three | 0 | 0 | the mirror case; not the C4 shape |
|
||||
| on 140 s, W 240, addPeer at the heal | same | v3, 240, 140 s | 2 (11, 12, first at 12 s) | 3 s (the harness now dials through `addPeer`; the address goes as `{ip, port}`) | 1 (12 by certificate; 11 verified on the new chain) | B, all three, A's split tip abandoned | 0 | 0 | PASS |
|
||||
|
||||
Reading. With the consensus fix and the sync hook, a node on the heavier chain that receives a certificate for a chain it has never seen fetches that chain, verifies the certificate at its own block, locks it, moves its sink to the lighter certified chain and re-determines its own records onto it (the v2 W 240 row: 0 conflicts, 0 disagreements, every node on B's chain, which is the spec's F1 and the design's Fork choice items 1 to 4). The same holds under rule v3 with the frozen table on (the last row: B certified 11 and 12 during a 140-s split, n0 reconnected 3 s after the heal once the harness dialled through `addPeer`, adopted 12 by certificate and ended on B's chain with the other two, 0 conflicts, 0 disagreements). The fix does not and cannot cover a partition that outlasts the bound before the certificate arrives (rows 2, 3 and 6): there the node has already locked alone and 3.11.4 keeps that lock, the late certificate is CONFLICTING for the operator. On the live devnet (W 7,200 DAA, two hours) the bound is two hours after a side's last lock, so every partition under that heals by certificate. Raw: `scratchpad c4-results-*.md`, node logs `c4-*-n0.log`.
|
||||
|
||||
## 5 October 2026 (evening), FUD ledger sweep round 6
|
||||
|
||||
Owner: the consensus engineer and cryptographer agent, worktree `igneum-wt-fud-a` (branch `fud-a`), 15:45 to 16:40 UTC. The Mac was loaded throughout (two cargo builds, a txgen run and a fee-switch simnet by other agents; load average over 100), so every figure below is a count, an index, a byte or a number from another machine; the only millisecond figures are the browser verifier's, taken as ratios and labelled. Live reads through the Mac node's wRPC (`ws://127.0.0.1:28640`) and the log intake (Neon HTTP SQL, lines split server-side), never a restart.
|
||||
|
|
@ -1667,3 +1693,38 @@ Chip model (`docs/analysis/chip-model-v3.md`): the on-die-cache recompute chip a
|
|||
|
||||
**Addendum, 22:15 UTC: the verifier regression, the PC 1 build rows, and x8 into v3.** The era agent measured the same v2 input with readwidth's binary (0.604 ms) and ca2-v3 HEAD's (1.33) in one minute; bisected under the measure lock to this branch's 0fc0ad1 (seam 6c75dad 0.610, 0fc0ad1 1.332; the "loaded box" reading above was wrong by that factor, the load was real but the 2x was the code). Cause: the item loop (`derive_items`) inlined into `MemhardCpu::fetch`; the mask hoisted, the mask constant, and the constant-mask loop inlined all stayed at 1.33, the same loop `#[inline(never)]` read 0.60 to 0.62. Fix: `derive_items_mask`, out of line, one instance per cache size with the line mask a constant. Measured the era agent's way (readwidth's binary beside the fixed one, same input, same minute, 22:07 UTC): v2 0.607 / 0.610 against 0.609 / 0.611; on the fixed binary x4 1.238 / 1.237 (2.0x), x8 2.077 / 2.058 (3.4x), worst cold 2.15 ms; the increments (+0.63, +1.46 ms per unit) equal the slow binary's. Lesson, the class: an inlined item loop costs 2.2x and nothing in the suite sees it; a verifier benchmark with a pinned bound in the crate's CI is filed for the next cut, and until then every change to the item loop is measured against the previous binary on the same input in the same minute. PC 1 (job run-mixer-x4-pc1-20261005, 22:00 to 22:04 UTC, the worker's `cache ... dataset ... ms` wall line): RTX 5090 dataset 23 to 25 ms at v2, x4 and x8; RX 9070 XT (gfx1201) 72 to 77 ms at all three; every fingerprint equal to the Mac's; rates the v2 rate (136.5 to 137.4 and 18.0 to 18.2 MH/s). Decision under the delegated rule (coordinator, 22:05 UTC): x8 enters class v3 (`V3_CLASS = MX8`); pinned packs mx8-genesis (7c28cfb06c5c65a9) and mx8-devnet-epoch0 through the chain path with the era inside (90f794dd556f7a3b, Metal and Apple OpenCL, 22:12 UTC); the x4 packs kept as the candidate's record. Chip headline at x8: 41.7 MH/s, 0.31x bare, 0.92x with the 3x factor, 0.76x at equal silicon (`docs/analysis/chip-model-v3.md`).
|
||||
|
||||
## 5 October 2026 (evening), EVM transaction relay: three nodes in a chain, every transaction sent to one end included by the other two miners (execution and networking engineer)
|
||||
|
||||
Until this change the node did not relay EVM transactions to its peers, so a transaction sent to one node was only ever included by that node's own templates (this file, "5 October 2026 (afternoon), live devnet: real transactions": 3,794 transfers, all in the Mac's blocks; execution-layer ledger item 9). Fork branch `tx-gossip` (worktree `vendor/igneum-node-txgossip`, from release-0.3.6 a24ab01a, commit e242acd0), main repo branch `tx-gossip`. Design in `docs/design/execution-layer.md` 1.4 "Relay"; the hand-out cooldown of its 10.2 table is gone with it (row "Mempool hold").
|
||||
|
||||
What was built. Three p2p messages after Kaspa's own transaction relay (`protocol/flows/src/v10/txrelay/flow.rs`): an inventory of admitted hashes, a request for the unknown ones, one answer with the raw bytes (`protocol/p2p/proto/p2p.proto`, payload numbers 72 to 74). Two flows per peer (`protocol/flows/src/v10/evmrelay.rs`), a pump that announces the mempool's admitted hashes every 250 ms, a sink trait the execution layer implements (`kaspa_consensus_core::evm::EvmTxSink`, `igneum/exec/src/service.rs EvmTxRelaySink`), and the mempool's side: every admitted hash queued for gossip, executed, evicted and invalid hashes remembered (65,536) so a second announcement is not requested, a 50,000-transaction cap, and the hold on block-added in place of the 4-second cooldown (the executor subscribes to consensus `BlockAdded`; a transaction leaves the templates when any DAG block carries it and comes back if a chain block skipped it). Limits per peer in the table.
|
||||
|
||||
| Limit | Value | Over it |
|
||||
|---|---|---|
|
||||
| Hashes announced to us, or requested from us | 2,000 per second, burst 8,192 | the surplus of the message is dropped (the sender paid as much as we did) |
|
||||
| Hashes per inventory or request message | 4,096 | disconnect |
|
||||
| Bytes per answer / per transaction | 4 MiB / 128 KiB | disconnect |
|
||||
| Transaction failing a state-free rule (malformed, signature, chain id, type 3 or 4) | | disconnect, hash remembered |
|
||||
| State-dependent refusal (nonce more than 16 ahead, fee cap under the base fee, funds, 64 queued per sender, pool full) | | dropped quietly, hash not remembered |
|
||||
|
||||
Protocol version. 13 to 14. An Igneum node drops a connection on a payload it cannot decode (`protocol/p2p/src/core/router.rs route_to_flow`: prost leaves the oneof empty, the router returns "empty payload", the connection closes), so the three messages go only to peers that advertised 14 or later, exactly as the finality (12) and proof-record (13) messages did. A 14 node registers the 13 flows for a 13 peer and never announces to it. The consensus params digest does not cover the protocol version: a scratch node on the devnet profile from the shipped 0.3.6 binary (`target-036`) and from this build printed the same digest, `9409dedac4bf9f0f20a54fb169b52a75a2903364909fe9ffd6fc5cdcd9d95d38`, so a 14 node and a 13 node still peer. Rollout: during the mixed fleet a transaction reaches the 14 nodes connected to the node it was sent to, and whatever a 13 node mines carries only what its own RPC received, as today; the relay is complete when the last miner is on 14. No fresh chain, no activation height.
|
||||
|
||||
Unit tests (PC 2, job build-20261005-173606, `igneum-exec` 15 of 15 in 0.01 s, `kaspa-p2p-flows` 33 of 33 in 0.19 s, 24 s for both): the pool queues an admitted hash for gossip once and answers "known" for the duplicate; wrong chain id, a signature above the curve order and truncated bytes are refused and remembered by hash, a nonce beyond the gap and a fee cap under the base fee are refused and not remembered; a transaction stays in every template until a block carries it, is held then, comes back when a chain block skips it and leaves (hash remembered) when one executes it; the wire messages round-trip through prost and the router's payload type, hash lists of the wrong length or over 4,096 are refused, the per-peer bucket grants the burst then the rate. The first PC 2 run of the suites (build-20261005-173013) failed on the signature case: a flipped low bit of `s` recovers a different signer (a funds refusal), not a fault; the test now sets `s` above the curve order. Found on the way: the `kaspa-p2p-flows` test target had not compiled since M20 added the epoch-seed headers to the pruning proof messages (`ibd/proof.rs` tests), fixed in the same commit.
|
||||
|
||||
The 3-node run (`tools/txgen/relay-net.mjs`, new; this Mac, load 7 to 8 at the end of the run after the other agents' harnesses finished, every number a count or an inclusion latency, not a timing of the node). Fast-time profile (`infra/fast-time/override-60x.json`, proof of work skipped), ports 29700+, data `/tmp/igneum-txrelay`. Chain A - B - C: B dials A and C (a harness node that dials accepts no inbound, and `--connect` takes one address per flag; both found by the first two runs, which are not numbers). A mines nothing. One vmine on B and one on C at 0.5 blocks/s each, paid to throwaway keys made for the run; B's rewards funded 16 generator wallets (2 IGN each) 33 s after start. The generator (`tools/txgen/run.mjs`) sent to A's EVM RPC only, 2 transfers a second for 120 s, so every inclusion is by a block B or C built from a pool the relay fed; C is two hops from A. Result files `docs/benchmarks/evm-relay-2026-10-05/{relay-report,txgen-summary}.json`.
|
||||
|
||||
| Measured, 3-node fast-time run (17:49 to 17:52 UTC) | Value |
|
||||
|---|---|
|
||||
| Sent to A / included / pending at the end / failures | 240 / 240 / 0 / 0 (0 nonce retries, 0 deferred, 0 throttled) |
|
||||
| Included per second over the send span | 1.98 (target 2) |
|
||||
| Inclusion latency p50 / p90 / p99 / max | 1,545 / 3,058 / 5,033 / 6,017 ms (mean 1,859) |
|
||||
| Chain blocks in the window / executed transfers / skipped copies | 149 / 256 (240 transfers and 16 funding) / 0 |
|
||||
| Included by miner B (one hop): blocks / with transactions / executed | 79 / 59 / 151 |
|
||||
| Included by miner C (two hops): blocks / with transactions / executed | 70 / 42 / 105 |
|
||||
| Pool depth, sampled every 5 s on A, B and C | equal on all three at 27 of 27 samples (0 to 6 pending), peak 6 |
|
||||
| Sinks agree at the end | yes |
|
||||
| First funding transfer, sent to A, included | 2.0 s after the send (block 37, mined by B or C) |
|
||||
|
||||
Reading. Every transaction given to A was mined by B or C within 6 s, two thirds of them within 3 s, with no skipped copy: the hold on block-added kept B's and C's parallel blocks from carrying the same transfer. The afternoon run on the live devnet, through one node with the cooldown, had p50 40.7 s and p90 110.8 s with 50-s quiet stretches; here the 1.5 s p50 is one fast-time block plus the relay and the executor's lag. The pool depth matching on all three nodes at every sample is the convergence. Not measured here: a transaction flood above the per-peer rate (the bucket is unit-tested only), a 13 peer in the fleet (the digest check and the version gate are the evidence), and the hold's 30-s expiry on a block that never reaches the chain (not seen in 149 chain blocks).
|
||||
|
||||
Commands: `IGNEUMD=vendor/igneum-node/target-txgossip/release/igneumd IGNEUM_MINER=vendor/igneum-node/target-txgossip/release/igneum-miner tools/lock/with-lock.sh run node tools/txgen/relay-net.mjs --rate 2 --duration 120 --wallets 16 --fund 2`; the Mac binaries from the fork worktree with `CARGO_TARGET_DIR=vendor/igneum-node/target-txgossip cargo build --release -j 4 -p kaspad -p igneum-miner --features kaspad/igneum-pow` under the build lock (an APFS clone of `target-036`, 2 min 15 s to clone, 5 min 06 s to build); the suites with `node tools/build-job.mjs run --target 1ccfe586 --node vendor/igneum-node-txgossip --targets linux --node-tests "igneum-exec kaspa-p2p-flows" --no-app`.
|
||||
|
|
|
|||
301
docs/benchmarks/evm-relay-2026-10-05/relay-report.json
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
{
|
||||
"tool": "tools/txgen/relay-net.mjs",
|
||||
"node": "/Users/joshm/Projects/igneum/vendor/igneum-node/target-txgossip/release/igneumd",
|
||||
"topology": "A - B - C (B dials A and C); generator on A; vmine on B and C",
|
||||
"params": {
|
||||
"rate_per_s": 2,
|
||||
"duration_s": 120,
|
||||
"wallets": 16,
|
||||
"fund_ign": 2,
|
||||
"fast_time": true
|
||||
},
|
||||
"chain_blocks_in_window": 149,
|
||||
"executed": 256,
|
||||
"skipped": 0,
|
||||
"by_miner": {
|
||||
"B": {
|
||||
"blocks": 79,
|
||||
"blocks_with_txs": 59,
|
||||
"txs_carried": 151,
|
||||
"executed": 151,
|
||||
"skipped": 0
|
||||
},
|
||||
"C": {
|
||||
"blocks": 70,
|
||||
"blocks_with_txs": 42,
|
||||
"txs_carried": 105,
|
||||
"executed": 105,
|
||||
"skipped": 0
|
||||
}
|
||||
},
|
||||
"generator": {
|
||||
"sent": 240,
|
||||
"included": 240,
|
||||
"pending_at_end": 0,
|
||||
"included_per_s": 1.975,
|
||||
"latency_ms": {
|
||||
"p50": 1545,
|
||||
"p90": 3058,
|
||||
"p99": 5033,
|
||||
"max": 6017,
|
||||
"mean": 1859
|
||||
},
|
||||
"blocks_with_content": 100,
|
||||
"errors": {},
|
||||
"stop_reason": "duration"
|
||||
},
|
||||
"pools": {
|
||||
"samples": [
|
||||
{
|
||||
"t": 33.5,
|
||||
"A": 0,
|
||||
"A_chain": 37,
|
||||
"B": 0,
|
||||
"B_chain": 37,
|
||||
"C": 0,
|
||||
"C_chain": 37
|
||||
},
|
||||
{
|
||||
"t": 38.5,
|
||||
"A": 6,
|
||||
"A_chain": 38,
|
||||
"B": 6,
|
||||
"B_chain": 38,
|
||||
"C": 6,
|
||||
"C_chain": 38
|
||||
},
|
||||
{
|
||||
"t": 43.5,
|
||||
"A": 2,
|
||||
"A_chain": 44,
|
||||
"B": 2,
|
||||
"B_chain": 44,
|
||||
"C": 2,
|
||||
"C_chain": 44
|
||||
},
|
||||
{
|
||||
"t": 48.5,
|
||||
"A": 2,
|
||||
"A_chain": 50,
|
||||
"B": 2,
|
||||
"B_chain": 50,
|
||||
"C": 2,
|
||||
"C_chain": 50
|
||||
},
|
||||
{
|
||||
"t": 53.5,
|
||||
"A": 1,
|
||||
"A_chain": 58,
|
||||
"B": 1,
|
||||
"B_chain": 58,
|
||||
"C": 1,
|
||||
"C_chain": 58
|
||||
},
|
||||
{
|
||||
"t": 58.5,
|
||||
"A": 2,
|
||||
"A_chain": 66,
|
||||
"B": 2,
|
||||
"B_chain": 66,
|
||||
"C": 2,
|
||||
"C_chain": 66
|
||||
},
|
||||
{
|
||||
"t": 63.6,
|
||||
"A": 4,
|
||||
"A_chain": 71,
|
||||
"B": 4,
|
||||
"B_chain": 71,
|
||||
"C": 4,
|
||||
"C_chain": 71
|
||||
},
|
||||
{
|
||||
"t": 68.6,
|
||||
"A": 2,
|
||||
"A_chain": 77,
|
||||
"B": 2,
|
||||
"B_chain": 77,
|
||||
"C": 2,
|
||||
"C_chain": 77
|
||||
},
|
||||
{
|
||||
"t": 73.6,
|
||||
"A": 0,
|
||||
"A_chain": 80,
|
||||
"B": 0,
|
||||
"B_chain": 80,
|
||||
"C": 0,
|
||||
"C_chain": 80
|
||||
},
|
||||
{
|
||||
"t": 78.6,
|
||||
"A": 3,
|
||||
"A_chain": 83,
|
||||
"B": 3,
|
||||
"B_chain": 83,
|
||||
"C": 3,
|
||||
"C_chain": 83
|
||||
},
|
||||
{
|
||||
"t": 83.6,
|
||||
"A": 0,
|
||||
"A_chain": 92,
|
||||
"B": 0,
|
||||
"B_chain": 92,
|
||||
"C": 0,
|
||||
"C_chain": 92
|
||||
},
|
||||
{
|
||||
"t": 88.6,
|
||||
"A": 0,
|
||||
"A_chain": 96,
|
||||
"B": 0,
|
||||
"B_chain": 96,
|
||||
"C": 0,
|
||||
"C_chain": 96
|
||||
},
|
||||
{
|
||||
"t": 93.6,
|
||||
"A": 2,
|
||||
"A_chain": 101,
|
||||
"B": 2,
|
||||
"B_chain": 101,
|
||||
"C": 2,
|
||||
"C_chain": 101
|
||||
},
|
||||
{
|
||||
"t": 98.6,
|
||||
"A": 1,
|
||||
"A_chain": 114,
|
||||
"B": 1,
|
||||
"B_chain": 114,
|
||||
"C": 1,
|
||||
"C_chain": 114
|
||||
},
|
||||
{
|
||||
"t": 103.6,
|
||||
"A": 5,
|
||||
"A_chain": 117,
|
||||
"B": 5,
|
||||
"B_chain": 117,
|
||||
"C": 5,
|
||||
"C_chain": 117
|
||||
},
|
||||
{
|
||||
"t": 108.6,
|
||||
"A": 4,
|
||||
"A_chain": 121,
|
||||
"B": 4,
|
||||
"B_chain": 121,
|
||||
"C": 4,
|
||||
"C_chain": 121
|
||||
},
|
||||
{
|
||||
"t": 113.6,
|
||||
"A": 2,
|
||||
"A_chain": 125,
|
||||
"B": 2,
|
||||
"B_chain": 125,
|
||||
"C": 2,
|
||||
"C_chain": 125
|
||||
},
|
||||
{
|
||||
"t": 118.6,
|
||||
"A": 1,
|
||||
"A_chain": 128,
|
||||
"B": 1,
|
||||
"B_chain": 128,
|
||||
"C": 1,
|
||||
"C_chain": 128
|
||||
},
|
||||
{
|
||||
"t": 123.6,
|
||||
"A": 0,
|
||||
"A_chain": 135,
|
||||
"B": 0,
|
||||
"B_chain": 135,
|
||||
"C": 0,
|
||||
"C_chain": 135
|
||||
},
|
||||
{
|
||||
"t": 128.6,
|
||||
"A": 3,
|
||||
"A_chain": 140,
|
||||
"B": 3,
|
||||
"B_chain": 140,
|
||||
"C": 3,
|
||||
"C_chain": 140
|
||||
},
|
||||
{
|
||||
"t": 133.6,
|
||||
"A": 0,
|
||||
"A_chain": 144,
|
||||
"B": 0,
|
||||
"B_chain": 144,
|
||||
"C": 0,
|
||||
"C_chain": 144
|
||||
},
|
||||
{
|
||||
"t": 138.6,
|
||||
"A": 1,
|
||||
"A_chain": 155,
|
||||
"B": 1,
|
||||
"B_chain": 155,
|
||||
"C": 1,
|
||||
"C_chain": 155
|
||||
},
|
||||
{
|
||||
"t": 143.6,
|
||||
"A": 3,
|
||||
"A_chain": 163,
|
||||
"B": 3,
|
||||
"B_chain": 163,
|
||||
"C": 3,
|
||||
"C_chain": 163
|
||||
},
|
||||
{
|
||||
"t": 148.6,
|
||||
"A": 2,
|
||||
"A_chain": 167,
|
||||
"B": 2,
|
||||
"B_chain": 167,
|
||||
"C": 2,
|
||||
"C_chain": 167
|
||||
},
|
||||
{
|
||||
"t": 153.6,
|
||||
"A": 1,
|
||||
"A_chain": 175,
|
||||
"B": 1,
|
||||
"B_chain": 175,
|
||||
"C": 1,
|
||||
"C_chain": 175
|
||||
},
|
||||
{
|
||||
"t": 158.6,
|
||||
"A": 0,
|
||||
"A_chain": 179,
|
||||
"B": 0,
|
||||
"B_chain": 179,
|
||||
"C": 0,
|
||||
"C_chain": 179
|
||||
},
|
||||
{
|
||||
"t": 163.6,
|
||||
"A": 0,
|
||||
"A_chain": 184,
|
||||
"B": 0,
|
||||
"B_chain": 184,
|
||||
"C": 0,
|
||||
"C_chain": 184
|
||||
}
|
||||
],
|
||||
"max": {
|
||||
"A": 6,
|
||||
"B": 6,
|
||||
"C": 6
|
||||
}
|
||||
},
|
||||
"sinks_agree": true,
|
||||
"wall_s": 168.8
|
||||
}
|
||||
269
docs/benchmarks/evm-relay-2026-10-05/txgen-summary.json
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
{
|
||||
"tool": "tools/txgen/run.mjs",
|
||||
"rpc": "http://127.0.0.1:29703",
|
||||
"chain_id": 4463,
|
||||
"started": "2026-10-05T17:49:40.914Z",
|
||||
"ended": "2026-10-05T17:51:44.556Z",
|
||||
"stop_reason": "duration",
|
||||
"params": {
|
||||
"wallets": 16,
|
||||
"rate_per_s": 2,
|
||||
"duration_s": 120,
|
||||
"fund_ign": "2.000000",
|
||||
"cap_ign": "74.000000",
|
||||
"gas": 59650,
|
||||
"stale_s": 60
|
||||
},
|
||||
"fees_last": {
|
||||
"base_gwei": "100.00",
|
||||
"proving_base_gwei": "10000.00",
|
||||
"tip_gwei": "1.00",
|
||||
"node_quote_gwei": "243.86",
|
||||
"fee_cap_gwei": "243.86"
|
||||
},
|
||||
"counts": {
|
||||
"sent": 240,
|
||||
"included": 240,
|
||||
"failed": 0,
|
||||
"dropped": 0,
|
||||
"skipped": 0,
|
||||
"reverted": 0,
|
||||
"nonceRetries": 0,
|
||||
"deferred": 0,
|
||||
"throttled": 0,
|
||||
"fundingTx": 16,
|
||||
"pending_at_end": 0
|
||||
},
|
||||
"throughput": {
|
||||
"included_per_s": 1.975,
|
||||
"send_span_s": 121.5,
|
||||
"sent_per_s_target": 2
|
||||
},
|
||||
"latency_ms": {
|
||||
"p50": 1545,
|
||||
"p90": 3058,
|
||||
"p99": 5033,
|
||||
"max": 6017,
|
||||
"mean": 1859
|
||||
},
|
||||
"blocks": {
|
||||
"with_content": 100,
|
||||
"first": 38,
|
||||
"last": 176,
|
||||
"max_tx_in_one": 10,
|
||||
"per_block": {
|
||||
"38": 8,
|
||||
"39": 1,
|
||||
"40": 2,
|
||||
"42": 3,
|
||||
"44": 5,
|
||||
"45": 3,
|
||||
"46": 1,
|
||||
"48": 1,
|
||||
"50": 2,
|
||||
"51": 1,
|
||||
"52": 3,
|
||||
"54": 2,
|
||||
"57": 3,
|
||||
"58": 2,
|
||||
"59": 1,
|
||||
"61": 2,
|
||||
"62": 2,
|
||||
"63": 1,
|
||||
"65": 1,
|
||||
"66": 3,
|
||||
"68": 4,
|
||||
"70": 1,
|
||||
"71": 5,
|
||||
"72": 1,
|
||||
"73": 2,
|
||||
"74": 2,
|
||||
"75": 2,
|
||||
"77": 2,
|
||||
"79": 10,
|
||||
"80": 1,
|
||||
"81": 4,
|
||||
"82": 2,
|
||||
"83": 3,
|
||||
"84": 2,
|
||||
"85": 3,
|
||||
"87": 1,
|
||||
"88": 2,
|
||||
"89": 1,
|
||||
"91": 1,
|
||||
"92": 4,
|
||||
"93": 1,
|
||||
"94": 3,
|
||||
"95": 2,
|
||||
"96": 2,
|
||||
"97": 1,
|
||||
"98": 5,
|
||||
"101": 2,
|
||||
"103": 1,
|
||||
"104": 1,
|
||||
"105": 2,
|
||||
"106": 2,
|
||||
"109": 1,
|
||||
"110": 1,
|
||||
"112": 1,
|
||||
"114": 3,
|
||||
"116": 3,
|
||||
"117": 8,
|
||||
"119": 2,
|
||||
"120": 1,
|
||||
"121": 6,
|
||||
"122": 1,
|
||||
"123": 3,
|
||||
"124": 2,
|
||||
"125": 6,
|
||||
"126": 1,
|
||||
"127": 4,
|
||||
"128": 3,
|
||||
"129": 1,
|
||||
"130": 4,
|
||||
"132": 1,
|
||||
"133": 1,
|
||||
"134": 1,
|
||||
"135": 2,
|
||||
"138": 4,
|
||||
"139": 1,
|
||||
"140": 4,
|
||||
"141": 6,
|
||||
"142": 3,
|
||||
"146": 1,
|
||||
"147": 1,
|
||||
"148": 3,
|
||||
"151": 2,
|
||||
"152": 1,
|
||||
"153": 1,
|
||||
"155": 4,
|
||||
"157": 1,
|
||||
"158": 1,
|
||||
"160": 1,
|
||||
"162": 1,
|
||||
"163": 5,
|
||||
"164": 1,
|
||||
"165": 4,
|
||||
"166": 1,
|
||||
"167": 5,
|
||||
"168": 1,
|
||||
"169": 1,
|
||||
"172": 3,
|
||||
"174": 1,
|
||||
"175": 3,
|
||||
"176": 2
|
||||
}
|
||||
},
|
||||
"spend_ign": {
|
||||
"funding": "32.000000",
|
||||
"fees_actual": "0.542976",
|
||||
"fees_max_committed": "38.769773",
|
||||
"value_moved_between_wallets": "0.132776",
|
||||
"cap": "74.000000",
|
||||
"cap_hit": false
|
||||
},
|
||||
"wallets": [
|
||||
{
|
||||
"index": 0,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.921973",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.922278",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.923568",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.924152",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.920937",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.922478",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.924611",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 7,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.930741",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 8,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.923430",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 9,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.926285",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 10,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.922495",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 11,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.918612",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 12,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.921628",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 13,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.921379",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 14,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.923181",
|
||||
"nonce": 15
|
||||
},
|
||||
{
|
||||
"index": 15,
|
||||
"sent": 15,
|
||||
"balance_ign": "1.923212",
|
||||
"nonce": 15
|
||||
}
|
||||
],
|
||||
"funder": {
|
||||
"balance_ign": "203.751501"
|
||||
},
|
||||
"lost": [],
|
||||
"pending_at_end": [],
|
||||
"errors": {}
|
||||
}
|
||||
|
|
@ -79,6 +79,8 @@ Worked example. Sender S has nonce 5. Miner A's block carries S:5, S:6. Miner B'
|
|||
|
||||
Consequence for users. A transaction can be skipped in one block and execute in a later one without being re-broadcast, as long as a miner includes it again; the node's mempool re-queues a skipped transaction once (then drops it). `eth_getTransactionReceipt` returns null until the executing copy lands, as on Ethereum for a pending transaction.
|
||||
|
||||
Relay (implemented 5 October 2026, fork `tx-gossip`, protocol version 14). A node's mempool is no longer only what its own RPC received. Every admitted hash is announced to every relay-aware peer within 250 ms (`IgneumEvmTxInvMessage`, the inventory pattern of Kaspa's `InvTransactions`); a peer requests the hashes it does not know (`IgneumRequestEvmTxsMessage`) and the holder answers one `IgneumEvmTxsMessage` with the raw bytes it still has; the receiver runs the same admission as `eth_sendRawTransaction` (signature, chain id, nonce window of 16, fee cap at or above the execution base fee, funds, 64 queued per sender, the pgas estimate) and a transaction the node already executed is refused without re-admission, so the pools converge and the chain's own blocks carry a transaction once. Dedup is by hash: the pool answers "known" for what it holds, executed or refused as invalid in the last 65,536 hashes, and one request per hash is outstanding across all peers. Per peer, 2,000 hashes a second with a burst of 8,192 are accepted in and served out; 4,096 hashes per message and 4 MiB per answer, over which the peer is dropped. A state-free fault (malformed, bad signature, wrong chain id, a refused type) disconnects the relaying peer, since every node refuses it the same way; a state-dependent refusal (nonce beyond the window, fee cap under the base fee, funds, queue depth, the 50,000-transaction pool cap) is dropped quietly, because the peer's tip may differ. Relay is off while the node is out of sync. Code: `protocol/flows/src/v10/evmrelay.rs`, the pump in `protocol/flows/src/service.rs`, the sink in `igneum/exec/src/service.rs` (`EvmTxRelaySink`).
|
||||
|
||||
Alternative. Per-block nonces or sequence-independent nonces (Sui-style objects). Rejected: every wallet assumes Ethereum nonces.
|
||||
|
||||
### 1.5 Invalid transactions are skipped by rule
|
||||
|
|
@ -460,7 +462,7 @@ Rule change, 4 October 2026 (findings F-exec-A and F-exec-B of the attack suite,
|
|||
| Units | 1 sompi = 1e10 wei; 1 IGN = 1e18 wei | Subsidies come from `igneum::block_subsidy` in 8-decimal sompi; the EVM is 18-decimal. The open "8 or 18 decimals" decision is unchanged; this is the fixed scaling at the bridge named there |
|
||||
| Rewards | 80% of every blue block's subsidy to its miner, 20% to the proving pool escrow `0x...0220`, both credited in the segment that merges the block; reds unpaid | Design 4.4, with the pool held in a keyless account until proof records exist |
|
||||
| Simnet | Devnet block rate and depths (1 BPS, k 18, mergeset 180, merge depth 3,600) with proof of work skipped; chain id 4463 shared with the devnet | A CPU test network of the devnet DAG shape |
|
||||
| Mempool hand-out | A transaction handed to a template is not offered again for 4 s unless a chain block skipped it; a transaction skipped twice is dropped | Kaspa removes a block's transactions on block-added; the cooldown is the stand-in until the executor listens to block-added |
|
||||
| Mempool hold | A transaction stays in every template until a block carrying it is added to the DAG (any block, this node's or a peer's: the executor subscribes to consensus `BlockAdded`); then it is held for 30 s or until the executor removes it (executed) or offers it again (a chain block skipped it); a transaction skipped twice is dropped | Kaspa's own rule (`mining/src/manager.rs`, `handle_new_block_transactions`). Replaced the 4-second hand-out cooldown on 5 October 2026 (fork `tx-gossip`, `pool.rs IN_BLOCK_HOLD`, `service.rs listen_block_added`): the cooldown made a sender mineable 1 s in 5 and inclusion came in 50-s bursts (bench-log, 5 October 2026 afternoon); with the hold a transaction is offered to every template until a block has it |
|
||||
| Reorgs | Post-segment states for the last 64 chain blocks; deeper reorgs replay from genesis | Observed depth on the test network: 1 to 3 with Poisson-paced miners. A fixed per-template hold had made the three stub miners mine in lockstep rounds, and with equal work per block the GHOSTDAG hash tie-break then kept two equal-work chains alive from genesis (flips 48 deep every few seconds); `igneum-miner --hold-ms` is exponential now |
|
||||
| Block tags | `pending`, `safe` and `finalized` all resolve to the executed tip | The virtual's segment is not executed eagerly and no certified checkpoint exists on this branch; the RPC does not pretend otherwise |
|
||||
|
||||
|
|
@ -474,7 +476,7 @@ Rule change, 4 October 2026 (findings F-exec-A and F-exec-B of the attack suite,
|
|||
6. The virtual's segment is not executed eagerly (design 1.2 "about one second after inclusion"); the executor runs about one chain block behind the sink. `pending` tags resolve to the executed tip.
|
||||
7. `eth_subscribe`, `debug_traceTransaction`, `trace_block`, `eth_getProof`, `eth_getUncle*`, `IgneumInfo`: not implemented.
|
||||
8. The chain follower polls `get_virtual_chain_from_block` every 100 ms instead of subscribing to virtual-chain-changed notifications.
|
||||
9. Mempool: no p2p relay of EVM transactions between nodes (each node's pool is what its RPC received), no eviction by age, no fee-based replacement beyond the 10% rule.
|
||||
9. Mempool: p2p relay of EVM transactions implemented 5 October 2026 (section 1.4 "Relay", protocol version 14; measured on a 3-node fast-time chain A - B - C in the bench-log of that day: every transaction sent to A was included by B's and C's blocks). Still missing: eviction by age and fee-based replacement beyond the 10% rule.
|
||||
10. Differential rows 1 (ethereum/tests), 3 (independent linearizer), 4 (Python oracle), 5 and 6 are not built; the harness here is the balance and receipt comparison of the acceptance criteria.
|
||||
|
||||
### 10.4 Merge plan with the finality branch
|
||||
|
|
|
|||
|
|
@ -621,6 +621,8 @@ Evidence: design doc Finality v2, Fork choice items 1 to 4; `sim/results.md` fin
|
|||
|
||||
Sweep (5 October 2026, evening): the module-on against module-off comparison of O-3.8, run on the fast-time harness with the live node line (`tools/finality-attacks/c4.mjs`, fork 2b6d23ef, 3 nodes, 100-ms proxied links; raw tables in `docs/bench-log.md`, "FUD ledger sweep round 6", C4). The scenario separates weight from work: side B (n1, n2, four keys) holds 70% of the weight table and side A (n0, two keys) 30% when the link is cut; from the cut A mines at 0.6 blocks/s and B at 0.4, so A's chain is the heavier one by blue work while only B can certify under rule v3 (A holds 30% of the frozen table). Module off (`min_daa` never, so no certificate can form, fork choice bare GHOSTDAG): after a 150-s split the three nodes converged on A's heavier chain within 36 s of the heal, B's nodes re-determined their two split-time checkpoints onto it (F24), 0 conflicts. Module on (rule v3 from checkpoint DAA 0), 90-s split, n0 back on the link 6 s after the heal, A's chain at about 58 DAA of its own time, well inside the 120-DAA frozen table: during the split A locked nothing and B locked indices 7 and 8 on its own blocks, as designed; after the heal n0 did not switch. Its log: B's certificates for 8 and 9 arrived and were "kept pending until the chain decides (no lock at this index)" (the F24 path), n0's chain never changed because GHOSTDAG prefers its heavier tip and nothing in the node turns a verified certificate over an off-chain block into a fork-choice constraint, and one window after n0's last lock (index 7 at DAA 209, so from DAA 329) the frozen table no longer applied on A's chain ("no frozen table (no lock on this chain inside the window)"), A's two keys were 100% of A's own window table (B's post-cut blocks are red there and earn nothing), and n0 locked 10, 11 and 12 alone; B's certificates for 10 and 11 then logged CONFLICTING on n0, and B's nodes kept their certified chain. End state: sinks apart, 2 locked indices disagreeing across the nodes, a finality fork from a 96-s honest partition with no attacker and the frozen table intact at the heal; the same shape with a 150-s split (the table expired at the heal) and under rule v2 (the control: 1 conflict, sinks apart). So the answer to the critic is sharper than conceded: the overlay is specified to override blue work (spec 3.5, "GHOSTDAG among tips through all certified checkpoints") but the shipped node applies a certificate only to a block on its own chain, holds the rest pending a reorg that GHOSTDAG alone never produces, and after one window the heavier side certifies its own chain. Two honest views never reconcile. What closes it: a verified certificate over a block the node does not have on its selected chain must verify against the weight table at THAT block (its signers' weight there) and, when valid, constrain fork choice to tips through it, forcing the reorg (a certificate-driven reorg, bounded by the finality depth), with the node's own unlocked records re-determined on the new chain (F24); until then the exchange guidance of 3.9 (a node partitioned for more than a minute treats its locks as proof of work until it has seen the network's certificates agree with its own) is the only protection, and the 3.11.7 row for this case ("a certificate over a chain the node is not on") is missing. On the live devnet the window is 7,200 DAA (two hours) and the cliff is two hours after a side's last lock; a miner who joins with more hashrate than the weight table credits is the realistic work-majority side. The trace-driven adversary of O-3.8 is still owed. Spec rows: 3.5, 3.11.4, 3.11.7; node: `processes/finality.rs` (`ingest_certificate`'s pending branch, `fork_choice_lock`). Decision owner: Josh (gate 3; a rule change to the node's fork choice).
|
||||
|
||||
Fix (5 October 2026, night): the certificate-driven reorg, built, unit-tested and measured; fork branch `c4-fix` on release-0.3.6 (a24ab01a), main branch `c4-fix`. The cause in the code: `processes/finality.rs` `ingest_certificate` verified a certificate only over the node's own determination and sent every other block to `hold_pending`; `fork_choice_lock` reads `state.locks`, which only `evaluate` filled over the node's own chain; so a certified block off the chain never became a lock. A second cause only the harness showed: the block relay (`protocol/flows/src/v10/blockrelay/flow.rs`) skips a relayed block lighter than the virtual's merge-depth root, and the certified chain is the lighter one by construction, so the heavier side never even received it. The fix: `ingest_off_chain` verifies a certificate against the voter table at its own block (canonical list, aggregate BLS, 2/3 of active and of total there, the frozen table under v3, the first-month gate), checks the block lies on the chain through the node's nearest locks (else CONFLICTING, 3.11.4, no lock withdrawn), locks the index on that block and asks the virtual processor to resolve (`VirtualStateProcessingMessage::Resolve`), so the sink search keeps only tips through it, whatever the blue work and whatever the merge depth (finality outranks merge depth; the depth-based finality point still bounds it, Kaspa's pruning safety, logged once); pending certificates over blocks the node lacks are retried on every virtual change; `evaluate` locks the node's own determination only on the chain through its locks; and while a pending certificate names a block the node lacks (`finality_wants_blocks`), the relay takes the lighter block, which orphans, falls out of range and triggers IBD of the certified chain. Not gated on v3: the live devnet's rule v2 took the same pending path (unit test `the_certificate_driven_reorg_holds_under_rule_v2`). Spec 3.5 carries the rule in one paragraph, 3.2 C4 and the 3.10 rows C4 and F1/F2 the implementation. Measured (bench-log "the C4 fix", `c4.mjs` with `WINDOW=240` so a 130-s split plus the 84-s p2p reconnect stays inside the window; the sweep's 120-DAA framing crosses F21's bound before any certificate can arrive once the real reconnect time is counted): under rule v2 side B locked index 12 during the split, n0 took B's first relayed block through the hook, locked 12, 13 and 14 by certificate within 2 s, re-determined 11, and all three nodes ended on B's certified chain with 0 CONFLICTING and 0 disagreeing locked indices (was: sinks apart, 5 conflicts on each node, 2 disagreeing); the module-off control is unchanged (heavier chain, 0 conflicts); under rule v3 (frozen table on, 140-s split, n0 reconnected 3 s after the heal) B locked 11 and 12 during the split, n0 adopted 12 by certificate and verified 11 on the new chain, all three nodes on B's chain, 0 CONFLICTING, 0 disagreeing; the mirror case (B certified nothing, A certified after the heal) had B's nodes adopt A's certificates and move before IBD. Unit tests on PC 2: `kaspa-consensus` 97 passed, `kaspa-consensus-core` 101 passed. Still owed: the live-devnet partition test of O-3.6 and the trace-driven adversary of O-3.8. What the fix does not cover, by design: a partition that outlasts the bound before the certificate arrives (the side has locked alone, 3.11.4 keeps it, the late certificate is CONFLICTING for the operator), which on the devnet means over two hours. Rollout: a consensus-behaviour change in the node with no params-digest change; a mixed fleet disagrees only in the state the old node already got wrong (an old node holds the certificate pending and stays on its heavier chain while new nodes move), and converges once every node is new; ship in the next node release with every node restarted on it.
|
||||
|
||||
### C5. vs Ethereum: you compare inclusion to finality
|
||||
"'Included in about one second, against twelve on Ethereum.' Inclusion in a DAG is not confirmation. Ethereum's twelve seconds is a slot, its finality is about thirteen minutes, and you compare your two-minute lock to that as if a two-minute lock by a pool committee were the same thing."
|
||||
|
||||
|
|
@ -1255,6 +1257,8 @@ Sweep (5 October 2026, evening): the two options, with their measured cost.
|
|||
|
||||
Recommendation: Option B, which spec 3.11.4 already states and O-3.17 names; it is Kaspa's rule for a finality conflict (`vendor/rusty-kaspa/consensus/notify/src/notification.rs`, `FinalityConflict`), it is the only reading under which an exchange can credit on a lock, and its cost falls on a state that needs a 34% equivocator or a 30-day partition. What it needs: the 3.5 paragraph replaced by 3.11.4's text, `finality_conflict` and the `finality_active` clear in the node, and the forced-double-certificate devnet test of 3.11.7. Decision owner: Josh (gate 3).
|
||||
|
||||
What the C4 fix changes for option B (5 October 2026, night): the honest-partition row above is gone. Before the fix a 96-s partition with the table intact put two certified chains on the network with no equivocator (C4: the work-majority side held the other side's certificates pending, then certified its own chain), and option B would have paused finality on every node of that side for an operator. With the certificate-driven reorg (spec 3.5, `ingest_off_chain`) a node that receives a valid certificate for a chain it is not on adopts it and moves, so after a heal shorter than a window there is one chain of locks and nothing to withdraw: the module-on harness ended with 0 conflicting certificates and 0 disagreeing locked indices on all three nodes, under rule v3 and under v2 (bench-log "the C4 fix"). What remains for option B is exactly the states 3.11.4 names: an equivocator at one third or more, and a partition longer than a window (both sides certify their own chain before the heal; the node then holds a lock at a higher index on the other chain, `off_lock_chain`, and the late certificate is CONFLICTING, kept for the operator, no lock withdrawn). The node still does not clear `finality_active` or expose `finality_conflict` (O-3.17).
|
||||
|
||||
Answer: Correct as the proposal stands. For an exchange "locked" must be irrevocable or it is a confirmation count. The alternative is Kaspa's: a verified certificate is never re-evaluated; two certificates at one index are a chain split that halts `finality_active` until an operator intervenes, and the node never reports a lock it may withdraw. Equivocation costing history and not coins (F6) means the attacker who caused the split keeps the deposit either way. Decision at gate 3; the devnet partition-and-heal test of O-3.6 measures whichever rule is chosen.
|
||||
|
||||
Evidence: spec 3.5, 3.9. Review id R3.17.
|
||||
|
|
|
|||
33
docs/plans/counter-asic-2.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Counter ASIC 2.0
|
||||
|
||||
Internal name, set by Josh on 5 October 2026 (evening), for the second set of chip-resistance layers on the lottery hash. The first set is live: a random program every hour from a VDF seed, era parameters drawn every six months, a dataset that grows on a genesis schedule, instruction families that unlock by height, warp-unit CPU verification. The public claim stays as it is: a chip gains under 2x over a GPU, the model is published, the bounty stands. Nothing here is active; every layer is measured first and decided by Josh, and every one that goes in goes in before the public testnet, as a genesis rule or a reserved family.
|
||||
|
||||
Trigger: the 9070 XT measurement of 5 October (bench-log "the 9070 XT on the eGPU"): the hash is bound by dependent random 4-byte reads, AMD fetches a 64-byte line per read, so AMD sits at a seventh of the 5090 and the three-vendor claim is bit-exact but not fair. Read-only data can also be mirrored into a chip's SRAM (256 MB is about 45 mm2 at a leading node, approximate).
|
||||
|
||||
## The layers
|
||||
|
||||
| # | Layer | What it takes from a chip | GPU cost | Step |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Load width 4, 16 or 64 bytes, every byte folded into the state | a fixed-width memory pipeline tuned to 4-byte reads | none if latency-bound stays | measure (running) |
|
||||
| 2 | Load width drawn per program from the seed, era-fixed mix | any fixed-width pipeline; vendor balance averages across hours | none | measure (running) |
|
||||
| 3 | Per-warp scratch in VRAM with read-modify-writes | the SRAM mirror of read-only data buys nothing for written scratch | measured share, expected 10 to 15% at 25% RMW (approximate) | measure (running), then soundness |
|
||||
| 4 | Table layout drawn per era: item size, stride, interleave | a layout-tuned chip loses at the next draw | none | design, era draw exists |
|
||||
| 5 | A second table sized to GPU cache, read beside the 1 GiB table | GPU-class SRAM and DRAM latency at once | small | next experiment |
|
||||
| 6 | Cache growth on the genesis schedule | the SRAM mirror stays unaffordable | none | already in the design; confirm the schedule against SRAM density |
|
||||
| 7 | Integer matrix ops in the program (INT8 x INT8 into INT32, exact) | matrix hardware at GPU scale | none on NVIDIA and AMD; Apple to check | reserved family, not at launch |
|
||||
| 8 | Working-set size drawn per program | one memory design cannot fit every hour | none | folds into 4 and 5 |
|
||||
|
||||
Not added: divergent data-dependent branches (cost GPUs more than chips), anything floating point (bit-exactness across vendors).
|
||||
|
||||
## The plan, in order
|
||||
|
||||
1. Experiment on branch `readwidth` (running): variants 1, 2, 3 on the M5 Max, the RTX 5090 and the RX 9070 XT; hash rate, bit-exactness across Metal, CUDA and OpenCL, CPU verifier cost, bytes per hash, latency-bound share, the chip model re-run per variant. Table in the bench log, recommendation in `docs/plans/read-width.md`. Decision rule: the widest read that keeps every card latency-bound with margin on the 5090.
|
||||
2. Decision 1 (Josh): the width and whether the per-program mix goes in. Consequences: new test vectors, new program id, spec sections on the hash and the litepaper Mining section rewritten, the soundness checks (uniformity, no out-of-bounds, fuzz) re-run on the new class.
|
||||
3. Experiment 2: layer 5 (the cache-sized second table) on the three cards, same measurements, plus layer 6's schedule checked against SRAM density per node (cite the source).
|
||||
4. Soundness project for layer 3 with the cryptographer role: what is written is uniform, no short-cut avoids the writes, the verifier's scratch simulation is exact; only then a vector.
|
||||
5. Decision 2 (Josh): layers 3, 5 and the era draws of 4 and 8, as genesis rules; layer 7 as a named reserved family in the genesis reserve, unlockable by height or by 90% signal.
|
||||
6. One generator change ships them all at once, before the public testnet, with the chip model and the before-and-after numbers published beside the litepaper claim.
|
||||
|
||||
## What stays true at every step
|
||||
|
||||
The latency bound is the property that matters, because DRAM latency is the same physics for everyone and a GPU already keeps thousands of loads in flight. Bandwidth is the property to avoid leaning on, because bandwidth per watt is what a custom memory chip buys (Ethash's chips got about 3x that way). Every layer above is checked against that rule by the latency-bound share in the measurements.
|
||||
129
docs/plans/explorer.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# Explorer: plan and recommendation
|
||||
|
||||
5 October 2026, after Josh read WhatToMine's listing requirements ("build it, and do we build our own explorer? who
|
||||
built etherscan?"). Branch `explorer`. What exists tonight: the public stats API (`docs/api/public-stats.md`) and the
|
||||
first DAG explorer pages on the site, fed by the observer. What is recommended: Blockscout for the EVM side, our own
|
||||
DAG and mining pages, one shared search box.
|
||||
|
||||
## 1. Who built Etherscan, and the open-source routes
|
||||
|
||||
| Explorer | What it is | Licence and cost | Fit for Igneum |
|
||||
|---|---|---|---|
|
||||
| Etherscan | Built and launched in 2015 by Matthew Tan (CEO and founder); the office since January 2017 (etherscan.io/aboutus, read 5 October 2026; the page does not name the city, Josh's brief says Kuala Lumpur). Since 2020 it sells "Explorer as a Service", a white-label instance for other chains, 40 clients by 2025 (same page) | Closed source, a private company; a chain pays for an instance. Price not published; not asked | Not for us: closed, paid, and it would show nothing of the DAG, the finality or the proving layer |
|
||||
| Blockscout | Open-source EVM explorer: blocks, transactions, accounts, verified contracts, token pages, an API in Etherscan's shape. Elixir (Phoenix) backend, PostgreSQL, a separate frontend; "several hundred chains and rollups" use it (README, read 5 October 2026) | "Blockscout Software Licence" (the README badge; the licence text was not read line by line, so what it permits for a hosted instance is unverified) | The EVM side for free: contracts, transactions, logs, tokens, an API developers already know |
|
||||
| Otterscan | "open-source, fast, local, laptop-friendly Ethereum block explorer": a React app over an Erigon archive node, using Erigon's custom `ots_` JSON-RPC methods (github.com/otterscan/otterscan, read 5 October 2026) | MIT (the app); the `ots_` API lives inside Erigon under its licence | Not for us: it needs Erigon's RPC extensions, which the Igneum node does not have, and it has no contract verification |
|
||||
|
||||
## 2. What Blockscout gives us for free, and what it costs to run
|
||||
|
||||
Blockscout indexes through standard JSON-RPC. Its documented requirements (docs.blockscout.com, read 5 October 2026):
|
||||
|
||||
| Item | Blockscout's figure | Igneum node today (`vendor/igneum-node` 0.3.6 fork, `igneum/exec/src/rpc.rs`, 938 lines) |
|
||||
|---|---|---|
|
||||
| Software | Erlang/OTP 26, Elixir 1.15.x, Postgres 14+, Node.js 18.x.x (docs: setup/requirements/requirements) | n/a |
|
||||
| Hardware, the docs' base line | "16 core, 32 thread", "128GB" RAM; the AWS example is one m5a.xlarge (4 vCPU, 16 GB) application server with 8 GB EBS and one db.t3.large RDS Postgres 14+ with 500 GB "depending on chain size" (docs: setup/requirements/resource-requirements) | A devnet at 0 EVM transactions per block needs nothing like the base line; the AWS example is the honest size for a small chain |
|
||||
| Database | Ethereum mainnet 21,000 GiB, Sepolia 5,200 GiB, Ethereum Classic 555 GiB, Gnosis Chiado 470 GiB (docs: setup/requirements/database-storage-requirements, figures dated 23 December 2024) | Unmeasured for Igneum. At one chain block per second with empty blocks the row count is 86,400 blocks a day; the byte size per block is the thing to measure in the first week |
|
||||
| RPC it needs from every client | `eth_blockNumber`, `eth_call`, `eth_getBalance`, `eth_getCode`, `eth_getBlockByHash`, `eth_getBlockByNumber`, `eth_getTransactionByHash`, `eth_getTransactionByBlockHashAndIndex`, `eth_getTransactionByBlockNumberAndIndex`, `eth_getTransactionReceipt`, `eth_getUncleByBlockHashAndIndex`, `eth_getLogs` (docs: setup/requirements/node-tracing-json-rpc-requirements) | The fork answers `eth_blockNumber`, `eth_call`, `eth_getBalance`, `eth_getCode`, `eth_getBlockByHash`, `eth_getBlockByNumber`, `eth_getTransactionByHash`, `eth_getTransactionByBlockNumberAndIndex`, `eth_getTransactionReceipt`, `eth_getLogs` (the match arms of `rpc.rs`). MISSING: `eth_getTransactionByBlockHashAndIndex`, `eth_getUncleByBlockHashAndIndex` (design 8.2 says uncles are "always empty": the method still has to exist). Also absent from the fork but in the design table: `eth_getStorageAt` is present; `eth_getProof`, `eth_feeHistory` present; `web3_clientVersion`, `net_version`, `net_peerCount`, `net_listening`, `eth_syncing`, `eth_mining`, `eth_protocolVersion`, `eth_accounts`, `eth_getBlockReceipts`, `eth_getBlockTransactionCountByNumber`, `eth_maxPriorityFeePerGas`, `eth_gasPrice`, `eth_estimateGas`, `eth_sendRawTransaction` present |
|
||||
| Pending transactions | `txpool_content` (geth, erigon) or `parity_pendingTransactions` | Neither exists in the fork. Blockscout runs without it (the pending view stays empty) |
|
||||
| Internal transactions and block rewards | `debug_traceBlockByNumber` and `debug_traceTransaction` with `callTracer` (geth variant), or `trace_replayBlockTransactions` and `trace_block` (erigon, nethermind) | None of the four exist in the fork. Design 8.2 lists them as "Supported, revm inspectors"; `rpc.rs` has no `debug_` or `trace_` method today. Without them Blockscout shows no internal transactions and no block-reward rows, and the indexer's trace fetcher must be switched off (`INDEXER_DISABLE_INTERNAL_TRANSACTIONS_FETCHER`, Blockscout's env; unverified against the current version) |
|
||||
|
||||
Cost of one instance on Hetzner (the price list the seeds are on, `docs/plans/seed-nodes.md`: cx23 2 vCPU 4 GB at USD 6.49 net a month; larger types not priced here): Blockscout's own AWS example is 4 vCPU 16 GB plus a 2 vCPU 8 GB database. The matching Hetzner shape is one box in the 8 GB to 16 GB class plus Postgres on the same box for a devnet, a second box for the database when the chain carries real traffic. Price it from the Hetzner API when the box is ordered; the figure here is approximate: USD 15 to 40 a month for the single box, under USD 80 for two. Plus an Igneum node on the same box or next to it (Blockscout wants a local, unlimited RPC; the public `rpc.testnet.igneum.network` is rate limited to 20 req/s, `docs/plans/testnet-go.md`).
|
||||
|
||||
What it costs in work, in hours not weeks: the two missing `eth_` methods (small, same shape as their by-number siblings); a decision on tracing (the `debug_` namespace with revm inspectors, design 8.2, is the larger piece and is not needed to run Blockscout without internal transactions); Blockscout's env file and a Docker compose on the box; the chain's entry in its config (chain id 4463 devnet, 4462 testnet, 4461 mainnet, design 8.1); contract verification through Sourcify or Blockscout's own verifier microservice.
|
||||
|
||||
## 3. What Igneum needs that must be ours
|
||||
|
||||
Blockscout shows a chain: numbered blocks, one parent, transactions, accounts. Igneum's execution layer is such a chain (RPC "blocks" are chain blocks, design 8.2), so Blockscout is right for it. Everything the consensus layer adds is invisible to it:
|
||||
|
||||
| Need | Where it comes from | State tonight |
|
||||
|---|---|---|
|
||||
| The DAG: every block, its parents, blue or red, pending, the selected chain, the mergeset of each chain block | the observer's `blockAdded` feed (`live_blocks`, `detail.mergeset`) | `/explorer` table and `/block/<hash>` built; the live DAG picture stays on `/live` |
|
||||
| Blue score and DAA score per block, the miner's vote key, the engine tag | the block header and coinbase | built |
|
||||
| Miners: payout address (the coinbase's `IGNA` tag) and the coinbase address, blocks mined, what they earned, balance | observer columns `evm_miner`, `miner_address`; `eth_getBalance` through `EXPLORER_EVM_RPC` | `/address/<addr>` built; balance shows when the deployment has an EVM RPC (none public for the devnet; `rpc.testnet.igneum.network` for the testnet) |
|
||||
| The lottery program per epoch (which generated program is live, the epoch seed, the VDF) | the node reports `epochSeed` per shard plan (`igneum_getShardPlan`); the program id and the epoch boundary are not in any RPC the observer reads | not built; needs an RPC for the current program id and epoch (open) |
|
||||
| Finality: checkpoints every 30 blue score, locks at two thirds of all 30-day weight, certificates, voter tables, the in-browser verifier | `live_checkpoints`, `live_certificates`, `/api/checkpoint`, `site/verify` | the block page shows a block's checkpoint state and certificate; a checkpoints list page is not built |
|
||||
| Proof records and shards: the plan per chain block, who proved what, lag, payout | `live_proofs`, `igneum_getProofRecords` | the block page shows the shards and the records a block carries; a provers page (per prover: shards, lag, income) is not built |
|
||||
| Pool payouts | a public pool does not exist (section 5) | not built |
|
||||
| The switches: `difficulty_v2_activation_daa`, `proving_v0_activation_daa`, `fees_v1_activation_daa`, `finality_v3_activation_daa` (`/tmp/igneum-devnet/override-v3.json` on the devnet: 33,000, 84,100, 210,000, 135,200) | the params file; `igneum_getProvingStatus.activationDaa`; no RPC lists them all | not built; a "network parameters" card on `/explorer` reading a params RPC is the clean way |
|
||||
|
||||
## 4. Recommendation
|
||||
|
||||
Confirmed from the code and the RPC surface: Blockscout for contracts, transactions and accounts; our own DAG, mining,
|
||||
finality and proving pages on the site, fed by the observer; one search box that routes a transaction or contract to
|
||||
Blockscout and a block hash, a chain block number or a miner to our pages. Two things qualify it:
|
||||
|
||||
1. Blockscout cannot run against the fork as it is: `eth_getTransactionByBlockHashAndIndex` and
|
||||
`eth_getUncleByBlockHashAndIndex` are missing (an hour), and there is no tracing (`debug_traceTransaction`), so
|
||||
internal transactions stay off until the revm inspectors of design 8.2 exist. The execution engineer owns both.
|
||||
2. One box of our own and Postgres on it, next to a node with an unlimited local RPC. Not before the public testnet
|
||||
has transactions worth looking at; the devnet's blocks are empty and our pages already show them.
|
||||
|
||||
The search box: `site/lib/explorer.mjs` `classify()` already routes 64-hex to a block (the API falls through to a
|
||||
transaction hash in the last 24 hours), 0x40 and bech32 to an address, digits to a chain block number. When Blockscout
|
||||
is up, a 64-hex that is not a DAG block and a 0x40 that is a contract go to it instead of a 404.
|
||||
|
||||
## 5. What was built tonight
|
||||
|
||||
| Item | Where |
|
||||
|---|---|
|
||||
| `/api/stats`, `/api/supply` | `site/api/stats.mjs`, `site/api/supply.mjs`, `site/api/_neon.mjs`; the emission rule in `site/lib/emission.mjs`; `docs/api/public-stats.md` with example responses |
|
||||
| `/api/explorer` | `site/api/explorer.mjs`: latest blocks, one block, height, address, search |
|
||||
| Pages | `site/explorer.html`, `site/block.html`, `site/address.html`; `site/vercel.json` rewrites `/block/:id` and `/address/:addr`; the footer carries an Explorer link (the nav is unchanged: its eight items are measured to fit at 941 px, a ninth is a layout decision for the site owner) |
|
||||
| Observer | `tools/observer/observer.mjs`: per-block explorer columns and `detail`, `number` from the shard plan, `rpc_load`, hourly `supply_check` |
|
||||
| Preview | `node tools/site-serve.mjs` (clean URLs, the rewrites, the API functions in-process; `LIVE_TABLE_PREFIX`, `EXPLORER_EVM_RPC`, `PORT`) |
|
||||
| Tests | `site/lib/explorer.test.mjs` (router, formatters), `site/lib/emission.test.mjs` (the node's own test values from `igneum.rs`, a devnet coinbase, the floor sum against a loop), `site/api/public-stats.test.mjs` (every documented field from a fixture); CI runs them, and on master `tools/ci/public-api-check.mjs https://igneum.network` |
|
||||
| Screenshots | `docs/plans/explorer/explorer.png`, `block.png`, `address.png` (local preview against a test observer, 5 October 2026) |
|
||||
|
||||
### Observer load, measured
|
||||
|
||||
Two copies of the observer ran side by side against the observer node (`ws://127.0.0.1:28640`, EVM RPC
|
||||
`http://127.0.0.1:26790`) with test table prefixes, 19:21 to 19:28 UTC on 5 October 2026: master's code with only a
|
||||
call counter added, and this branch. Calls per wall-clock minute, as each process counted them:
|
||||
|
||||
| Minute (UTC) | Before, wRPC | After, wRPC | Before, EVM | After, EVM |
|
||||
|---|---|---|---|---|
|
||||
| 19:21 to 19:22 (partial, 50 s) | 283 | 282 | 776 | 785 |
|
||||
| 19:22 | 211 | 211 | 2,119 | 2,145 |
|
||||
| 19:23 | 252 | 253 | 2,487 | 2,493 |
|
||||
| 19:24 | 232 | 232 | 2,487 | 2,492 |
|
||||
| 19:25 | 266 | 266 | 2,491 | 2,497 |
|
||||
| 19:26 | 210 | 210 | 2,485 | 2,481 |
|
||||
|
||||
Ratio after to before: 1.00 on both. The explorer reads everything from the notification the observer already
|
||||
receives; the only new work is one SQL update per shard-plan batch. The EVM figure is the proving feed's record
|
||||
polling (80 blocks per 2 s tick while proving is active, master behaviour); the observer that is live tonight points
|
||||
at `http://127.0.0.1:26800` (the app's node, down), so its EVM load is zero and its proving feed reads "unreachable".
|
||||
The observer node answers the proving RPCs on 26790 (`eth_chainId` 0x116f, `igneum_getProvingStatus` active); pointing
|
||||
`IGNEUM_EVM_RPC` there when the observer is next restarted is a one-line change to the launch environment, not to the code.
|
||||
|
||||
### Supply check, measured
|
||||
|
||||
The first sample after start: "21 blocks, payload subsidy = rule 21/21, outputs = merged subsidies 20/20 (1 without a
|
||||
verdict)". The rule in `site/lib/emission.mjs` reproduces the node's own test values (`igneum.rs`: 3,168,808,781 at
|
||||
DAA 2,592,000; 1,584,404,390 at the first halving; 0 at the 32nd) and the devnet coinbase of block `2622db76` (payload
|
||||
subsidy 454,486,399 at DAA 125,064; outputs 363,588,240 + 90,897,059 = 454,485,299 = E(125,063), what its merged
|
||||
parent declared: `utxo_validation.rs:176` pays a merged block the subsidy its own payload carries).
|
||||
|
||||
## 6. A public pool with a stats API (separate item, not built)
|
||||
|
||||
The app carries a pool mode in the Hive package: the Flight Sheet's Pool URL is `grpc://<your node>:26610` for solo
|
||||
mining or `local` to run the bundled node on the rig (`packaging/hive/README.md`); "there is no pool". A public pool
|
||||
needs, in order: the pool protocol of `docs/spec/09-pool-protocol.md` implemented (templates, shares, the member's
|
||||
own vote key and votes relayed, vardiff, the `stats` message: member `hashrate`, `workers`, `refusals`, `shares`, pool
|
||||
`members`, `hashrate`, `blocks_24h`, `declared_share`); a pool server on its own box with a node; a payout scheme (the
|
||||
spec leaves PPLNS windows and PPS fees to the pool); a stats API in the shape WhatToMine and MiningPoolStats read
|
||||
(pool hashrate, miners, workers, blocks found with heights and times, fee, minimum payout, luck); and a page on the
|
||||
site. None of this is in the repo; the stats API of this branch is the network side of what those sites ask for.
|
||||
|
||||
## 7. Unverified
|
||||
|
||||
- Vercel's `cleanUrls` with a rewrite destination of `/block` and `/address` (the clean names of `block.html` and
|
||||
`address.html`): checked locally through `tools/site-serve.mjs`, not on a Vercel preview, because nothing was pushed.
|
||||
- `/api/stats` and `/api/supply` on the live tables: the live observer has not been restarted on this code, so the
|
||||
live `live_blocks` has no `number`, `tx_count` or `detail` columns yet; the handlers answer with nulls there until
|
||||
the restart (the schema adds itself on start, `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`). The examples in
|
||||
`docs/api/public-stats.md` come from the test observer's tables.
|
||||
- Blockscout's licence terms for a hosted instance, and the exact env flag that disables its trace fetcher.
|
||||
- The database size per Igneum block in Blockscout, and the Hetzner price of the box: approximate above, measure and
|
||||
price when ordered.
|
||||
- `EXPLORER_EVM_RPC` on Vercel: no public devnet EVM RPC exists, so balances show "no EVM RPC configured" on the devnet
|
||||
deployment; the testnet's `https://rpc.testnet.igneum.network` is the value for the testnet.
|
||||
BIN
docs/plans/explorer/address.png
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
docs/plans/explorer/block.png
Normal file
|
After Width: | Height: | Size: 318 KiB |
BIN
docs/plans/explorer/explorer.png
Normal file
|
After Width: | Height: | Size: 392 KiB |
171
docs/plans/miner-ui-2.md
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
# Igneum Miner UI 2: sections behind a rail, 5 October 2026
|
||||
|
||||
Josh, 18:40 UTC: "can any updates be made to the UI of the miner? different sections for different features. Made
|
||||
super simple for users, all settings super easy and simple and everything looking gorgeous, as close to shippable
|
||||
as we can get right now." Worktree `/Users/joshm/Projects/igneum-wt-miner-ui`, branch `miner-ui-2` from master
|
||||
a93199a. Josh approved the redesign from the screenshots (20:1x UTC) and moved it into 0.3.10: the 0.3.10 shipper merges
|
||||
`miner-ui-2` last and rebuilds the app. Times are UTC.
|
||||
|
||||
## 1. What changed
|
||||
|
||||
| Where | What |
|
||||
|---|---|
|
||||
| `app/igneum-app/ui/index.html` | One page became a rail with six sections: Mine, Prove, Rewards, Node, Updates, Settings. The welcome, GPU and address screens and the one-time key sheet stay as they were. The settings panel, the bottom bar and the proving tile are gone; every control they held is placed on a section. |
|
||||
| `app/igneum-app/ui/app.css` | Rewritten around the rail, the thin top bar, the status strip and the pages. Same tokens as the site (obsidian, graphite, ember, molten, bone, ash; Unbounded, IBM Plex Sans, IBM Plex Mono). One accent. Lays out from 900 x 600: under 1000 px the rail folds to icons, the number strips to two columns, the two-column grid to one. Focus rings on every control (`:focus-visible`, the switch tracks). |
|
||||
| `app/igneum-app/ui/app.js` | New pure block `View` (the words every section shows for a state), tested by `view.test.mjs`. The Notices and UpdateCard blocks, the log drawer and the blocks canvas are unchanged. `?page=<name>` forces a section and `?logs=1` opens the drawer (screenshots). |
|
||||
| `app/igneum-app/ui/view.test.mjs` | 10 tests: the GPU row (names, kinds, integrated off, temperatures amber then red, unknown numbers blank), the big button, the node words, the next switch, the prove words, the dev-fee lines, the jobs line. Added to `.github/workflows/ci.yml`. |
|
||||
| `app/igneum-app/src/state.rs`, `engine.rs` | Engine addition 1: `node.consensus_digest`, the node's own "Consensus params digest" line (`digest_from_line`, tested; a peer's digest in a WARN line is never taken). Addition 2: `node.consensus_switches`, every `*_activation_daa` in the override file the node was started with, lowest first, in plain words (`switches_of`, tested). |
|
||||
| `app/igneum-app/src/prover.rs` | Engine addition 3: `proving.program_id` and `proving.aggregator_id` from `igneum-prove-host --mode id` (`ids_from_describe`, tested), read once at thread start on macOS and Linux and when the WSL2 host is found on Windows, proving on or off. |
|
||||
| `app/mac/IgneumMiner.swift` | Window minimum 900 x 600 (was 900 x 620). |
|
||||
| `app/windows/host.cpp` | One `WM_GETMINMAXINFO` case: minimum 900 x 600 (there was no minimum). |
|
||||
|
||||
The API contract is otherwise as it was: the UI calls the same routes (`api/state`, `api/cards`, `api/pause`,
|
||||
`api/resume`, `api/prove`, `api/settings`, `api/update/*`, `api/jobs/*`, `api/sweep/*`, `api/key/*`, `api/log`,
|
||||
`api/open`, `api/quit`, `api/clock/sync`, `api/power/apply`, `api/detect`, `api/phase`, `api/setup`, `api/start`).
|
||||
|
||||
## 2. The sections
|
||||
|
||||
| Section | What it holds | Where it was |
|
||||
|---|---|---|
|
||||
| Mine | One big Start mining / Stop mining button (pause and resume), hash rate, blocks found, next program; one row per GPU with its real name, kind tag (Integrated shown as such and off by default, the engine's rule), on/off switch (applies at once; the other cards keep their identities and caps), hash rate, temperature and power where the card reports them; the blocks strip; Activity | the dashboard strip, the Cards card, the Pause button, the Events card |
|
||||
| Prove | The proving switch with three plain sentences on what proving is; state, assigned, proven, paid (IGN when paid); the verifier's state with one line; the shard program id and the aggregator id with Copy; Set up when the WSL2 host is missing | the Proving tile and the Settings switch |
|
||||
| Rewards | The address with one Copy; blocks found, this run, balance; the Save your key card (two lines, Show my key, Hide) or the "you pasted your own address" line; Use another address; the dev-fee line; the wallet page | the Settings address block |
|
||||
| Node | Node state, height, peers, version, each with a plain line; the chain numbers; the consensus digest with Copy; the next switch with its height and how far away; every switch with the applied ones dimmed; finality; the clock card when the clock is off | the Node and Finality cards |
|
||||
| Updates | The version, Check now, Install now when a download is ready, the auto-update switch; remote jobs: Check now, the state line, the history table, the signing key | the Settings version and remote-jobs blocks |
|
||||
| Settings | Per card: power cap slider with the watt number (NVIDIA; Apple silicon says it manages its own power), identities stepper, sweep line with Sweep now / Stop / Unpin, the cap-applied or Retry line, draw and temperatures; the sweep switch; start at login; remote jobs allowed; vote on checkpoints; the machine name; the dev fee stated plainly; Copy the log and Show the log, the log and chain folders; Advanced (collapsed): the devnet trust switch, the live page | the Settings panel |
|
||||
| Status strip | Unchanged (Notices): updates, remote jobs, the clock, one at a time, under the top bar | the same place |
|
||||
| Rail foot | Machine name, rewards address, Logs (the drawer), Quit, version and chain | the bottom bar |
|
||||
|
||||
Every control in Settings is one switch, one slider or one field with one line of help under it. Nothing was
|
||||
removed: the identities stepper, the sweep buttons, the power Retry, the trust switch, the live page, the machine
|
||||
rename, the key reveal, the log drawer with its chips, search, ruler and jump controls all still exist.
|
||||
|
||||
Empty, loading and error states: Mine says "Asking the graphics cards to report in" then "No GPU this app can drive
|
||||
was found" (red) with the detect message; the big button is disabled with a reason while no card is on, the engine
|
||||
is away or the app quits; Prove says off, needs setup, waiting, proving, submitted or idle, each with a sentence;
|
||||
Node says starting, syncing with a percentage and the ETA, synced, restarting, failed or stopped, and the digest
|
||||
box says "not printed yet" or "the node is not running"; Updates says "Not checked yet" or "This build has no update
|
||||
address"; the jobs table says "No job has run on this machine yet"; Settings says "No card yet"; a lost engine
|
||||
turns the pill to "engine away" and the Mine and Node words to "no answer".
|
||||
|
||||
## 3. Proof
|
||||
|
||||
The engine was built from this worktree on the Mac under the build lock (`with-lock.sh build nice -n 19 cargo build
|
||||
--release -j 4`, rustup's cargo 1.99; Homebrew's cargo 1.69 in PATH cannot read the lock file) and run on its own
|
||||
port and data directory in devnet v4 mode (`IGNEUM_APP_BIN` = the installed 0.3.9 bundle's `Resources/bin`,
|
||||
`IGNEUM_APP_DATA`, `IGNEUM_APP_LOGS`, `IGNEUM_APP_NODE_DIR` under the session scratchpad, RPC 27610, P2P 27611,
|
||||
a scratch `igneum-app.json` next to the binary with the devnet override params, no update manifest) through
|
||||
`with-lock.sh run`. The live Mac app's engine, node 1 and the observer were not touched; the test node peered with
|
||||
the seed and node 1 and synced 123,559 blocks in about 12 minutes, then the M5 Max mined on it (22 MH/s, digest
|
||||
1f4b4425..., the devnet's). The setup flow was walked in the built-in browser pane (Get started, the GPU switch,
|
||||
Make me an address, the key sheet, Start mining) and every section was clicked through, the rail driven with the
|
||||
arrow keys, and the window checked at 900 x 600.
|
||||
|
||||
Tests: `node --test notices.test.mjs update-card.test.mjs view.test.mjs` = 20 pass, 0 fail; `cargo test --release
|
||||
--bin igneum-app -- digest_comes switches_are pinned_ids` = 3 pass (the three new engine tests, run on the Mac
|
||||
under the build lock; the full suites go to PC 2 with the 0.3.11 cut as usual).
|
||||
|
||||
The PNGs below were taken with the installed Mac host's snapshot mode (`"Igneum Miner" --snapshot <png> --url <the
|
||||
test engine> --size 1200x780`, a real WKWebView, Retina 2400 x 1560), in `docs/plans/miner-ui-2/`.
|
||||
|
||||
| File | Shows | Placeholder or sample |
|
||||
|---|---|---|
|
||||
| `00-welcome.png` | The welcome screen (unchanged) | none |
|
||||
| `01-setup-gpu.png` | Step 1, the M5 Max row with its switch | none |
|
||||
| `02-setup-address.png` | Step 2, make an address or paste one | none |
|
||||
| `03-setup-key.png` | The one-time key sheet | the key is zeros (`?screen=key` sample) |
|
||||
| `04-mine.png` | Mine while mining: the big button, 23 MH/s, the GPU row, the blocks strip | temperature and power say n/a on Apple silicon (the engine has no reading for it) |
|
||||
| `05-prove-off.png` | Prove with proving off, the verifier verifying, both pinned ids | none |
|
||||
| `06-prove-on.png` | Prove with proving on: Proving (CPU), block 35507 shard 0 | none (the Mac CPU prover was switched on for 25 s, then off) |
|
||||
| `07-rewards.png` | Rewards: the address, 343 lifetime blocks, Save your key, Use another address | the balance cell says "--, shown in the wallet, not here yet" (no API) |
|
||||
| `08-node.png` | Node synced: height, 2 peers, version 2.1.0, the digest, next switch Fees v1 at 210,000, finality | none |
|
||||
| `09-updates.png` | Updates: 0.3.9, Check now, auto-update, remote jobs | this build has no manifest, so "no update address" and "no jobs address" |
|
||||
| `10-settings.png` | Settings: the card block, the sweep switch, this machine, dev fee, logs, Advanced collapsed | the NVIDIA power slider is not in the shot (no NVIDIA card on this Mac); its markup is in `setCardHtml` |
|
||||
| `11-logs-drawer.png` | Mine with the log drawer open (chips, search, ruler, follow, close) | none |
|
||||
| `12-strip-update.png` | The status strip with "Igneum Miner 0.3.7 is available", Install now, Later | `?update=available` sample |
|
||||
| `13-strip-job.png` | Updates with a running remote job in the strip and the table | `?job=running` sample |
|
||||
| `14-node-syncing.png` | Node while syncing: 59,602 of 123,559 (48%), the next switch from that height | none |
|
||||
| `15-mine-syncing.png` | Mine while the node syncs: the button says Stop mining, waiting for the node to sync | taken before the blank marker in the GPU row became `n/a` (it shows a dash) |
|
||||
| `16-narrow-900-mine.png` | Mine at 900 x 620 (the installed host's minimum; the new hosts say 600): the icon rail, two-column numbers, the power column dropped | none |
|
||||
| `17-narrow-900-settings.png` | Settings at 900 x 620 | none |
|
||||
| `18-narrow-900-node.png` | Node at 900 x 620 | none |
|
||||
|
||||
## 4. Shippable now, placeholder, follow-ups
|
||||
|
||||
Shippable now: the six sections, the rail, the strip, the drawer, every setting, the three engine fields, the
|
||||
hosts' minimum size. The Windows host line is untested on Windows (one `WM_GETMINMAXINFO` case; the 0.3.11 cut
|
||||
builds it on the runner as usual). The NVIDIA power slider, telemetry and sweep lines on Settings and the
|
||||
temperature and power columns on Mine are rendered from the same state fields the old tiles used, but no NVIDIA
|
||||
card was on this Mac, so they are unseen in these shots: the PC screenshot is the first thing to look at after
|
||||
the cut.
|
||||
|
||||
Placeholder: the balance cell on Rewards ("shown in the wallet, not here yet").
|
||||
|
||||
Follow-ups (engine work, not built here; the budget was three small additions):
|
||||
|
||||
| Follow-up | What it needs |
|
||||
|---|---|
|
||||
| Balance on Rewards | the engine reads `eth_getBalance` for the rewards address from the node's EVM RPC every 30 s (`update.rs` has the curl shape) and puts `address.balance_wei` on the state |
|
||||
| "Pause while I use the machine" | does not exist in the engine: an idle-input reading per platform and a pause/resume on it; the Settings switch is one line once the field exists |
|
||||
| Log export as a file | today Copy the log puts the last 2,000 ring lines on the clipboard and the folder path is shown; a `/api/log/export` that writes the ring to the log folder and reveals it in Finder or Explorer needs a `platform::reveal_path` |
|
||||
| Temperature and power on Apple silicon | the engine reads no telemetry for Metal; `powermetrics` needs root, so this stays blank unless a non-root source is found |
|
||||
| A per-card "pause this card" without restarting the others | exists already through the row switch (only that card's worker restarts); nothing to do unless Josh wants a timer |
|
||||
|
||||
## 5. The 0.3.10 merge: gpu-hotplug (4d122e1) resolved on the new UI
|
||||
|
||||
The branch was merged with `gpu-hotplug` 4d122e1 (the 0.3.10 tree carries it). The engine, relay and tooling files
|
||||
merged by themselves; `app.js` and `app.css` were taken from this branch and the hot-plug UI ported by hand:
|
||||
|
||||
| Hot-plug state | On the six-section UI |
|
||||
|---|---|
|
||||
| The strip notices (card added, mining; new card not usable (Code 43); card removed) | the Notices block is gpu-hotplug's, unchanged; `notices.test.mjs` (12) passes |
|
||||
| A card the OS reports a problem on (`problem`, "Code 43") | Mine row: name in ember, the word "not usable (Code 43)", the reboot hint under it, the switch disabled; the Mine note repeats the hint; first-run row and Settings card say the same, with no controls |
|
||||
| A removed card (`removed_at`) | Mine row dimmed, the word "removed", "unplugged; its worker stopped. The row goes in five minutes."; no switch; Settings card says the same; after five minutes (`gone`) the row is not shown |
|
||||
| The counts and the big button | only present cards count (a removed or faulty card never makes the button say "Stop mining") |
|
||||
| The name tooltip | the tool's code (gfx1201), the device index, the OpenCL platform, the PCI address (`View.cardTitle`) |
|
||||
| The card list sent on a switch | removed and faulty cards are left out, as gpu-hotplug's `readCardRows` does |
|
||||
|
||||
`view.test.mjs` has an eleventh test for the two states. The whole app crate's unit tests pass on the merged tree
|
||||
(99, on the Mac under the build lock, `cargo test --release --bin igneum-app`). `relay/test/parse.test.mjs`
|
||||
passes. The Mac engine builds and runs (the same scratch instance as section 3).
|
||||
|
||||
Build tooling for the Windows compile: `packaging/windows/push-build-inputs.sh --no-node` and
|
||||
`node tools/build-job.mjs run --no-node` pack and build the app engine only (no node source, no node build, no node
|
||||
tests), so an app-only change compiles on PC 1 in a fraction of the full job.
|
||||
|
||||
PC 1 job (20:21Z, from this tree at d62b445, `node tools/build-job.mjs run --target ae432dc7 --no-node --targets
|
||||
windows --no-tests --no-place`): extract 167 files, `windows app/igneum-app build exit 0 6 s` (the PC's target dir
|
||||
was warm), `igneum-app.exe` 2,970,112 bytes sha256 62ce96a9..., PE check ok, coin icon and version block ok, done
|
||||
after 13 s. The exe carries the new UI and engine strings ("Prove shards on this machine" x2, `consensus_switches`
|
||||
x6, "Consensus params digest:" x1, `card:removed:` x1), so the 6 s was a real compile of the merged sources, with
|
||||
gpu-hotplug's Windows-only `detect::adapters` path and this branch's prover change in it. Downloads under the session
|
||||
scratchpad (`pc1-app/`), not placed.
|
||||
|
||||
The ship tool runs no node tests of its own (`tools/ship-app.mjs --self-test` is the version bump), so the only
|
||||
list to carry `view.test.mjs` is `.github/workflows/ci.yml` (done in 83a293f).
|
||||
|
||||
What the PC job does not compile: `app/windows/host.cpp` (the window host with the `WM_GETMINMAXINFO` and
|
||||
`WM_DEVICECHANGE` cases) is built by the GitHub runner (`windows.yml`) at the shipper's push, never on a PC; the
|
||||
`WM_GETMINMAXINFO` case is three lines of plain Win32 (`MINMAXINFO`, `ptMinTrackSize`) and reads at the runner's log.
|
||||
|
||||
## 6. The changelog paragraph for the 0.3.10 manifest notes (what the user sees)
|
||||
|
||||
The miner has six sections behind a rail: Mine, Prove, Rewards, Node, Updates, Settings. Mine has one big Start
|
||||
mining / Stop mining button, the hash rate, the blocks found, and one row per graphics card with its name, an on/off
|
||||
switch, its hash rate, temperature and power. Prove says in plain words what proving is and shows the shards
|
||||
assigned, proven and paid, the verifier and the program ids. Rewards shows the address with one Copy and keeps the
|
||||
key backup in its own card. Node shows the sync, height, peers and version with a plain line under each, the
|
||||
consensus digest, and the list of planned rule switches with the next one and its height. Updates holds the
|
||||
version, Check now and the remote jobs. Settings has one switch or slider per setting with one line of help under
|
||||
each: the power cap per card with the watt number, identities, start at login, remote jobs, voting, the dev fee
|
||||
stated plainly, the log. The window lays out from 900 x 600. Not in this release: the balance on Rewards (shown in
|
||||
the wallet), a "pause while I use the machine" switch, log export as a file, temperature and power on Apple
|
||||
silicon.
|
||||
|
||||
## 7. The branch
|
||||
|
||||
| Commit | What |
|
||||
|---|---|
|
||||
| 83a293f | the UI pass, the three engine additions with tests, the hosts' minimum size, this plan and the 19 screenshots (the blank marker in a GPU row is `n/a`, not a dash: copy law) |
|
||||
| 364feef | this plan's hash line |
|
||||
| d62b445 | Merge gpu-hotplug 4d122e1: the hot-plug states on the new UI, `--no-node` for the build tools |
|
||||
BIN
docs/plans/miner-ui-2/00-welcome.png
Normal file
|
After Width: | Height: | Size: 316 KiB |
BIN
docs/plans/miner-ui-2/01-setup-gpu.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
docs/plans/miner-ui-2/02-setup-address.png
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
docs/plans/miner-ui-2/03-setup-key.png
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
docs/plans/miner-ui-2/04-mine.png
Normal file
|
After Width: | Height: | Size: 281 KiB |
BIN
docs/plans/miner-ui-2/05-prove-off.png
Normal file
|
After Width: | Height: | Size: 338 KiB |
BIN
docs/plans/miner-ui-2/06-prove-on.png
Normal file
|
After Width: | Height: | Size: 327 KiB |
BIN
docs/plans/miner-ui-2/07-rewards.png
Normal file
|
After Width: | Height: | Size: 302 KiB |
BIN
docs/plans/miner-ui-2/08-node.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
BIN
docs/plans/miner-ui-2/09-updates.png
Normal file
|
After Width: | Height: | Size: 245 KiB |
BIN
docs/plans/miner-ui-2/10-settings.png
Normal file
|
After Width: | Height: | Size: 312 KiB |
BIN
docs/plans/miner-ui-2/11-logs-drawer.png
Normal file
|
After Width: | Height: | Size: 455 KiB |
BIN
docs/plans/miner-ui-2/12-strip-update.png
Normal file
|
After Width: | Height: | Size: 284 KiB |
BIN
docs/plans/miner-ui-2/13-strip-job.png
Normal file
|
After Width: | Height: | Size: 274 KiB |
BIN
docs/plans/miner-ui-2/14-node-syncing.png
Normal file
|
After Width: | Height: | Size: 342 KiB |
BIN
docs/plans/miner-ui-2/15-mine-syncing.png
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
docs/plans/miner-ui-2/16-narrow-900-mine.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
docs/plans/miner-ui-2/17-narrow-900-settings.png
Normal file
|
After Width: | Height: | Size: 231 KiB |
BIN
docs/plans/miner-ui-2/18-narrow-900-node.png
Normal file
|
After Width: | Height: | Size: 166 KiB |
489
docs/plans/release-0.3.10.md
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
# Igneum Miner 0.3.10: the certificate-driven reorg, transaction gossip, the pack loader fix, the six-section miner and the PC-built Windows node, 5 October 2026
|
||||
|
||||
Shipped: manifest 0.3.10 published 21:32:40Z, every reachable machine on 0.3.10 by 21:49:41Z, the hand nodes and the seed on 21d4c73c by
|
||||
21:50:15Z, one digest (1f4b4425...) on every node that restarted; PC 37ba0461 (the owner's laptop) mid-install and Sam's Mac quit at the
|
||||
time of writing. The cut ran from 16:39Z to 21:5xZ, two hours of it GitHub's hosted-runner outage.
|
||||
|
||||
Release engineer, from 16:39 UTC. Worktree `/Users/joshm/Projects/igneum-wt-ship0310`, branch `release-0.3.10` from master
|
||||
after the 0.3.9 merge. Fork worktree `vendor/igneum-node-0310`, branch `release-0.3.10` from a24ab01a (the 0.3.9 fork tip).
|
||||
Every Mac build through `/Users/joshm/Projects/igneum/tools/lock/with-lock.sh build` at `nice -n 19` with `-j 4`; every PC
|
||||
job through the main checkout's `tools/build-job.mjs` and `packaging/ota/publish-jobs.sh` (the signed envelope). Times are
|
||||
UTC. `release-0.3.9.md` and `release-0.3.8.md` are the template; `release-0.3.6.md` section 10 for the Windows acceptance.
|
||||
The rollout waited for 0.3.9's (shipped about 17:00Z, fee switch published about 17:50Z): nothing of 0.3.10 moved on the
|
||||
network before every node's digest was ab8847da...
|
||||
|
||||
## 1. What 0.3.10 carries
|
||||
|
||||
| Change | Where | Fork commit |
|
||||
|---|---|---|
|
||||
| A. The windows-gnu node links the C++ runtime statically (`database/build.rs`, `rocks-probe`), so the PC-built Windows node runs (release-0.3.6 plan section 10) | fork `housekeeping` 4fb32865 | merged 2f88a82f |
|
||||
| B. `TestConsensus` sets the unbounded PoW cache build queue, so the five node suites pass in parallel on PC 2 | fork `housekeeping` 7003055b | merged 2f88a82f |
|
||||
| C. The certificate-driven reorg (ledger C4, measured in FUD round 6, 75c6658: a certificate over an off-chain block stayed pending and the heavier side locked alone): a verified certificate over a block off the node's selected chain now locks it there and moves the virtual to the heaviest tip through it (spec 3.5 F1 and F2); the block relay takes a lighter block a pending certificate names. A consensus-behaviour change with NO digest change: it converges when every node is new (the c4 agent's rollout note, section 10) | fork `c4-fix` e18f1e0e (its message records PC 2 job build-20261005-180827: kaspa-consensus 97 passed, kaspa-consensus-core 101 passed, three new tests), main `c4-fix` 68f14b9 (spec 3.5, 3.2, 3.10, 3.11.7, ledger, bench-log, c4.mjs v2 mode; the signer-pipe-check CI script and per-job build-inputs zip names) | fork: merged 21d4c73c; main: 0cbaf3a and the tip merge |
|
||||
|
||||
Changelog line for C (the coordinator's words): "A node that comes back from a partition now follows the certified checkpoints, even when its own chain is heavier."
|
||||
| D. EVM transaction relay between nodes: three p2p messages after Kaspa's transaction inv/request/answer, PROTOCOL_VERSION 13 to 14 (13 peers still connect: a v14 node never sends the new messages to an older peer), the mempool caps and faults, the block-added hold instead of the 4-s hand-out cooldown; the digest is unchanged, so no activation height and no fresh chain | fork `tx-gossip` e242acd0 (its message records PC 2: igneum-exec 15 of 15, kaspa-p2p-flows 33 of 33), main `tx-gossip` 0166e94 (the relay-net harness `tools/txgen/relay-net.mjs`, the design note); acceptance here: `node tools/txgen/relay-net.mjs --rate 2 --duration 120 --wallets 16 --fund 2` on the 0.3.10 Mac node, expected 240 of 240 included | fork: merged 21babaa7; main: (pending the worktree) |
|
||||
|
||||
Changelog line for D (the coordinator's words): "Transactions now travel between nodes; a miner on an older node still carries only what was sent to it directly."
|
||||
| E. GPU hot-plug (coordinator, 17:5xZ): cards re-enumerated every 60 s and on `WM_DEVICECHANGE`, new cards mine by default, Code 43 cards marked unusable, vanished cards dropped without shifting slots, the Ryzen gfx1036 iGPU classified integrated and off by default, a `cards:` line the relay parses (machines API `gpus`/`hotplug`). 14 files: `app/igneum-app/src/{detect,engine,hotplug,main,state}.rs`, `ui/{app.css,app.js,notices.test.mjs}`, `app/windows/host.cpp`, `relay/{api/console.mjs,lib/parse.mjs,test/parse.test.mjs,ui.html}`, `tools/console.mjs`; no proving, packaging or workflow file. Its Windows-only paths (`detect::adapters`, `host.cpp` WM_DEVICECHANGE) were never compiled on the Mac: PC 1's Windows app build and the GitHub runner's host build are their first compile and are read for errors | main `gpu-hotplug` 4517004 (`igneum-wt-hotplug`, on master 22ffc02); its tests on the branch: igneum-app 91 pass, notices, update-card and parse node tests pass | merged after C and D |
|
||||
| F. Elevated jobs that never started (coordinator, 18:0xZ): an elevated job whose UAC prompt is refused, cancelled or times out fails with exit 251 and the summary "did not start: the administrator prompt was refused, cancelled or timed out"; before, `Start-Process` threw, `$p` stayed null and `exit $p.ExitCode` exited 0, so the PC 1 driver job at 17:58Z read as done. One file, `app/igneum-app/src/jobrun.rs`, plus its unit test | main `elevated-exit` 9816d58 (`igneum-wt-elevated`) | merged after E |
|
||||
| G. The miner UI redesign (Josh, through the coordinator at 19:0xZ: into 0.3.10, not 0.3.11): six sections (Mine, Prove, Rewards, Node, Updates, Settings), one switch per card, one line of help per setting, the node's next switch shown with its height; three engine additions with tests (`state.rs` `node.consensus_digest` and `node.consensus_switches`, `engine.rs`, `prover.rs` program ids via `--mode id`), a Mac window minimum of 900 x 600, one `WM_GETMINMAXINFO` case in `app/windows/host.cpp`, `view.test.mjs` in CI | main `miner-ui-2` (`igneum-wt-miner-ui`, 83a293f and 364feef, then the commit its agent reports after resolving it against gpu-hotplug's card-row states) | merged LAST, after F, at the agent's final commit; the node stays 21d4c73c, so the app, the DMG, CI and the ship files are rebuilt (section 7a) |
|
||||
| H. The pack loader fix (the outage of 18:31Z, root cause found by the coordinator's agent): the generator retries a rejected candidate program with `seed || k` (epoch 34's attempt 0 was rejected on the saturation rule, attempt 1 accepted) and the workers' pack loader (`proto-cuda/nvrtc/packfile.h`, shared by the OpenCL worker) derived the expected seed words from attempt 0, so every worker started after the boundary refused a correct pack. Changes `packfile.h`, `worker.cpp`, `proto-opencl/host.c`, the relay's parse and tests, an emu test. It changes the Windows WORKER binaries: since 0.3.6 the payload has carried only the worker sources (`host.cu`, `host.c`, `build.bat`) and the PCs built the workers themselves, so the fix reaches the PCs only as exes: both workers cross-built on this Mac (`proto-cuda/nvrtc/build-windows.sh`, mingw, the staged redist) and carried by `push-inputs.sh` into the installer; a hot-fix copy is already on both PCs under `packs\\workers-fix`. The CI builds no worker (`windows.yml` copies the two worker exes and the `nvrtc*.dll` files from the signed inputs only), so the rebuilt pair reaches the installer through `push-inputs.sh` and nowhere else; the engine takes a bundled worker before its own PC-built one (`detect.rs` `Bins`, `engine.rs` `build_worker_from_source` only "when no prebuilt worker ships"), so the fix applies at the first start after the update | main `pack-loop` af983a7 (`igneum-wt-pack-loop`; the tip at merge time) | merged after G |
|
||||
| I. The export lock: `engine.rs` serialises `igneum-miner export-pack` behind `EXPORT_LOCK` around both call sites (two per-card export threads interleaved into one folder across an epoch change on PC 1, a second way to a wrong pack) | main `opencl-rdna4` a08c371: the lock hunk is the part that must ship; the rest (host.c's duplicate-platform fold, `--readback select`, `--memprobe`, `detect.rs parse_opencl_list`) only if it merges cleanly with hotplug and the OpenCL worker cross-builds without error, else the hunk alone (coordinator, 19:2xZ) | (decided at the merge, section 2) |
|
||||
| NOT in 0.3.10 unless the coordinator says so: `job-console` d33a266 (one hidden-console builder for every elevated launch, the spawn check in CI, PC 1 console watchers; its agent's message at 18:5xZ) and `opencl-rdna4` a08c371 (its agent's message at 19:1xZ: the OpenCL worker folds the duplicate AMD platform out of `--list`, a GPU select pass, `--memprobe`, `detect.rs` parsing with tests, and `engine.rs` serialises `export-pack` behind `EXPORT_LOCK` because PC 1's `packs\devnet` has held a mismatched `program.h` and `seeds.txt` since 19:07Z: the pack-export class the rollout is held for; passed to the coordinator as a candidate) | the coordinator's rule: a late branch goes in only if it lands before the final tree; the final tree b958493 was pushed at 18:36Z with CI green at 18:42Z. job-console also moves elevated-exit's exit-251 launcher text to `platform.rs`, so its `include_str!` test is repointed at its own merge to master after 0.3.10 | next cut |
|
||||
| The app engine otherwise | unchanged except the version (0.3.10 in the six files); the signed jobs envelope client is master's (housekeeping C, 318a2de) | |
|
||||
|
||||
Changelog line for G (the coordinator's words): "The miner is now six sections: Mine, Prove, Rewards, Node, Updates, Settings. One switch per card, every setting with one line of help, the node's next switch shown with its height."
|
||||
|
||||
Changelog line for F (the coordinator's words): "A remote job that needs administrator rights now reports when the prompt was not accepted instead of claiming success."
|
||||
|
||||
Changelog line for E (the coordinator's words): "New cards are picked up while the miner runs; a card that goes away is released; integrated GPUs stay off unless you switch them on.
|
||||
| The pinned guest | UNCHANGED unless C or D touches `proving/igneum-prove/core` or `elf/` (checked at the merge: section 2) | |
|
||||
|
||||
## 2. The branch
|
||||
|
||||
| Commit | What |
|
||||
|---|---|
|
||||
| 9268b64 | `origin/master` at the worktree's creation (18:00Z: the 0.3.9 merge, fast-forwarded) |
|
||||
| 2e943a8 | Merge `tx-gossip` (0166e94): the relay design note, rows 463 and 9, the 3-node relay harness and its evidence; no conflict |
|
||||
| 824059b | `Igneum Miner 0.3.10: the six version files` (`node tools/ship-app.mjs --check`: 0.3.10 in all 6) |
|
||||
| 9997ef1 | `packaging/mac/packaged-config.sh`: the four-field override object in the packaged line, the self-test fix (section 3) |
|
||||
| 0cbaf3a | Merge `c4-fix` (3072919, 18:20Z). One conflict, `docs/bench-log.md`: both entries kept (the fee-table entry, then the C4 entry). Brings `tools/ci/signer-pipe-check.sh` (`igneum-ota-sign ... \| head -1` under pipefail panicked the signer with SIGPIPE on a loaded Mac and killed a publish; now `sed -n 1p` in `publish-jobs.sh`, `publish-manifest.sh`, `push-inputs.sh`) and one `build-inputs-<time>-<pid>.zip` per build job (`build-job.mjs`, `push-build-inputs.sh --name`: with the shared name a job pinned another agent's sources three times tonight). From here every PC job and publish runs from THIS worktree's tools: they carry master's signed envelope (housekeeping C) and these two fixes, which the main checkout lacks until this branch merges |
|
||||
| 4d10c20 | Merge `gpu-hotplug` at its tip at merge time, 4d122e1 (the agent's second commit: one row per physical GPU across OpenCL platforms, Windows names on the rows, index-free card keys, the OpenCL worker's `--list` prints the PCI address in `proto-opencl/host.c`); no conflict, 15 files |
|
||||
| 5520fb1 | Merge `elevated-exit` (9816d58); no conflict |
|
||||
| d1f4923 | Merge `origin/master` a93199a (docs and site only) |
|
||||
| e0a5fd1 | `infra/cross/build-linux.sh`: the three paths made absolute before the cd (section 3) |
|
||||
| 065c67c | `packaging/windows/node-source.pin` = 21d4c73c (push-inputs, section 5c) |
|
||||
| b958493, ccd2b98 | the site as the pre-push hook builds it; the plan so far. The FIRST final tree: pushed 18:36:44Z, CI green 18:42Z, installer and DMG built (section 7). Then Josh's scope change reopened it for G |
|
||||
| 7f9618a | Merge `miner-ui-2` at its agent's final commit a3d9f2d (19:2xZ; it already carries gpu-hotplug 4d122e1 merged and resolved onto the new UI). One conflict, `packaging/windows/push-build-inputs.sh`: the usage comment only (c4's `--name` text against miner-ui-2's `--no-node` text; both options were already in the code), both kept |
|
||||
| 5abc709 | Merge `pack-loop` (af983a7, its tip at merge time). One conflict, `relay/test/parse.test.mjs`: the import line (the union: `parseAppTail`, `parseCardsLine` from hotplug, `PACK_MISMATCH` from pack-loop) and two tests that both landed at the file's end (hotplug's cards line, pack-loop's pack mismatch), both kept; `node --test relay/test/parse.test.mjs`: 7 pass |
|
||||
| 854f9a8 | `engine: one pack export at a time`: the `EXPORT_LOCK` hunk of opencl-rdna4 a08c371 (`git diff origin/master...a08c371 -- app/igneum-app/src/engine.rs`, applied clean: 9 lines, a static mutex and a guard at both export-pack call sites). The rest of that branch conflicts with hotplug in `detect.rs` and `proto-opencl/host.c` (a dry-run merge at 19:1xZ), so by the coordinator's rule it waits for the next cut |
|
||||
| 854f9a8 | the fast checks on the SECOND final tree, 19:24Z: identity 0 hits over 213 files, copied-sources, pinned-guests, signer-pipe-check ok, no-conflict-markers ok, workflow shell 0 findings, `--check` 0.3.10 in all 6, relay tests 17 pass, UI tests 23 pass (notices 12, update-card 6, view 5; `view.test.mjs` in ci.yml line 85) |
|
||||
| 5520fb1 | the fast checks on the first final tree: identity 0 hits over 212 files, copied-sources, pinned-guests, signer-pipe-check ok, no-conflict-markers ok, workflow shell 0 findings, `--check` 0.3.10 in all 6, relay tests 16 pass (hotplug's parse test added), UI tests 12 pass; 18:21Z |
|
||||
|
||||
A `vendor` symlink to the main checkout's `vendor/` (untracked) makes the relative node paths resolve, as in the earlier cuts.
|
||||
The app and prover target dirs were cloned by APFS from the 0.3.9 worktree (18:01Z).
|
||||
|
||||
The fork branch `release-0.3.10` in `vendor/igneum-node-0310`, from a24ab01a: 2f88a82f (housekeeping 4fb32865), 21babaa7 (tx-gossip e242acd0), 21d4c73c (c4-fix e18f1e0e: `finality.rs`, `blockrelay/flow.rs` and five small files, 601 insertions), all three without conflict. Neither c4-fix nor tx-gossip touches a params file, `proving/igneum-prove/core` or `elf/`: the pinned guest and the prover rollout are untouched by 0.3.10.
|
||||
|
||||
## 3. Tests and checks, with the command
|
||||
|
||||
| Tip | Command | Result |
|
||||
|---|---|---|
|
||||
| fork 2f88a82f (a24ab01a + housekeeping) | PC 2 job `build-20261005-164604` (`node tools/build-job.mjs run --node vendor/igneum-node-0310 --target 1ccfe586 --targets linux --node-tests "kaspa-consensus kaspa-consensus-core igneum-exec kaspa-pow igneum-miner" --no-app`) | started 16:46Z, done 16:48:43Z (129 s): Linux node built, `RESULT test node [the five packages] exit 0 39 s`: the parallel five-package run is green on the housekeeping tree (housekeeping B); the short report carries the per-package exit, not the per-test names |
|
||||
| fork 2f88a82f | PC 1 job `build-20261005-164512` (`--targets linux,windows --no-tests --no-app --no-place`) | started 16:45Z, done 16:50:26Z (292 s), every stage ok, 7 files, all sha256 and PE checks ok: igneumd.exe 37d0b1045043bc86... (50,889,728), igneum-miner.exe bc8cb3a12111e7bb... (10,725,888), Linux igneumd d0331c109babe77e... (48,733,736, glibc 2.39: HiveOS, not the seed). `strings igneumd.exe`: msvcrt.dll is the only C runtime named; no libstdc++-6, libgcc_s_seh-1 or libwinpthread-1 (housekeeping A) |
|
||||
| fork 2f88a82f, PC 1 exe | run job `run-0310-accept-hk` (the housekeeping agent's acceptance script: rocks-probe, igneumd 60 s on a scratch appdir with no mingw DLL beside it, `igneum-miner.exe key-hash probe`, Application event 1000 check), published 16:51:01Z (the apps woken) | ran 16:57:15 to 16:58:21Z (66 s), exit 0: `rocks-probe` exit 0 (21 files in its db), `RESULT run node60: still running after 60 s (alive for the whole wait); killing it` with 14 files written under the scratch appdir, `igneum-miner.exe key-hash probe` exit 0, `RESULT event 1000: none`; the exe's imports on the PC (objdump): KERNEL32, advapi32, bcrypt, iphlpapi, msvcrt, ntdll, ole32, ws2_32 and the api-ms-win-core set, no mingw DLL. The one warning, `cannot bind the eth_ JSON-RPC server on 127.0.0.1:26790`, is PC 1's live node holding the port, as in the housekeeping run. So the PC-built Windows node is the 0.3.10 Windows node (no Mac cross-build needed) |
|
||||
| fork 2f88a82f, Mac arm64 | `CARGO_TARGET_DIR=vendor/igneum-node/target-0310 cargo build --release -j 4 -p kaspad -p igneum-miner --features kaspad/igneum-pow` from `vendor/igneum-node-0310` under the lock (target dir cloned by APFS from `target-036`; a new worktree path rebuilds every crate) | 16:44:52 to 16:57:28Z (12 min 36 s): igneumd 2a7df6245ffe047a... (40,968,368, `2f88a82f` in its strings), igneum-miner 322472ae95a316d1... (8,514,928) |
|
||||
| fork 2f88a82f, Mac arm64 | the digest check: that igneumd on a scratch node (ports 60975/60976, 22 s, 16:57:58Z) with `{"difficulty_v2_activation_daa":33000,"proving_v0_activation_daa":84100,"fees_v1_activation_daa":210000}` | `Calibrated v1 fees from the override file: ... from DAA score 210000`, digest ab8847da538dead1dc10e046dfaadab3c1c35928e3748810c4e050d4a886087a, `igneumd/2.1.0-2f88a82f`: housekeeping moves no parameter |
|
||||
|
||||
| fork 2f88a82f, Linux x86-64 for the seed | `NODE_SRC=vendor/igneum-node-0310 TARGET_DIR=vendor/igneum-node/target-0310-linux infra/cross/build-linux.sh` (zig, glibc 2.36 target) under the lock | queued 16:57Z behind two other agents' builds, built 17:23 to 17:35:49Z (754 s, 564 crates), and its output was WRONG: igneumd 7e26e374... BYTE-IDENTICAL to the 0.3.9 build of a24ab01a, embedding `a24ab01a`. Cause (found at the second run, 18:25Z): `build-linux.sh` does `cd "$NODE_SRC"` and runs cargo with `CARGO_TARGET_DIR="$TARGET_DIR"`, so a RELATIVE target dir resolved inside the node worktree (`vendor/igneum-node-0310/vendor/igneum-node/target-0310-linux`, the untracked `vendor/` that appeared there), while the copy step read the repository-relative clone that still held the a24ab01a binary. Every crate had compiled, into the wrong place. Fixed on this branch (e0a5fd1: the three paths made absolute before the cd) and the build rerun with an absolute target (section 5). My first reading of it, a stale `kaspa-build-info` output in the cloned target, was wrong and is withdrawn |
|
||||
| fork 21babaa7 (2f88a82f + tx-gossip e242acd0), Mac arm64 | the same cargo build, incremental on `target-0310` | 17:49:52 to 17:52:00Z (2 min 08 s): igneumd 5d7f1b576029b462... (41,118,944, `21babaa7` in its strings) |
|
||||
| fork 21babaa7, Mac arm64 | the digest check as above (ports 60975/60976, 22 s, 17:52:01Z) | `Calibrated v1 fees ... from DAA score 210000`, digest ab8847da538dead1dc10e046dfaadab3c1c35928e3748810c4e050d4a886087a, `igneumd/2.1.0-21babaa7`: tx-gossip moves no parameter (its commit message says the protocol version is not in the digest; measured here) |
|
||||
| fork 21babaa7 | PC 2 job `build-20261005-175022` (`--node-tests "kaspa-consensus kaspa-consensus-core igneum-exec kaspa-pow igneum-miner kaspa-p2p-flows" --no-app`) | started 17:50Z, done 17:53:10Z (145 s): `RESULT test node [the six packages] exit 0 53 s` (kaspa-p2p-flows added: tx-gossip's relay tests live there and in igneum-exec) |
|
||||
| 2e943a8 + bump | `tools/ci/identity-check.sh` (0 hits over 212 files), `copied-sources-check.sh`, `pinned-guests-check.sh` (elf/ matches its manifest), `node tools/ci/check-workflow-shell.mjs` (12 run blocks, 25 .ps1, 0 findings), the relay tests (15 pass), the UI tests (10 pass) | all green, 18:00Z |
|
||||
| fork 21babaa7, Mac arm64 | the digest check with the FOUR-field object `{"difficulty_v2_activation_daa":33000,"proving_v0_activation_daa":84100,"fees_v1_activation_daa":210000,"finality_v3_activation_daa":135200}` (N3 = 135,200 from the live 0.3.9 manifest republished 17:59:14Z, deadline note "finality v3"; Josh: "deploy N3 now", the 0.3.9 agent publishing it) | 18:01:59Z, 22 s: `Finality rule v3 from the override file: active from checkpoint DAA score 135200`, `Calibrated v1 fees ... 210000`, digest 1f4b44255fcd2ea8f75664ed47200f409186ddd2292960c9e2cf95bbbdc11505, `igneumd/2.1.0-21babaa7`. Equal to the live hand nodes' lines at 18:02Z (`observer-v4.out`, `node1.out`: digest 1f4b4425..., `igneumd/2.1.0-a24ab01a`, the four-field `/tmp/igneum-devnet/override-v3.json`): the 0.3.9 agent's N3 rollout had reached them. This is the digest every node must show; the final 0.3.10 node is read again against it |
|
||||
| 9997ef1 | `packaging/mac/packaged-config.sh`: `NODE_OVERRIDE_PARAMS` = that four-field object (it was empty on master; the manifest's `consensus.override` is what the apps write, the packaged line covers a first start before any manifest). Its self-test's "json has no override line" check read the file's shipped default and failed on a non-empty line (master's empty line passed it), so that check now passes `NODE_OVERRIDE_PARAMS=''` itself, as the "override params land" check already passes its own value; `bash packaging/mac/packaged-config.sh --test`: all checks passed |
|
||||
| fork 21d4c73c (+ c4-fix), app 5520fb1 | PC 2 job `build-20261005-182244` (`--node-tests "kaspa-consensus kaspa-consensus-core igneum-exec kaspa-pow igneum-miner kaspa-p2p-flows" --app-tests igneum-app`) | 18:23:15 to 18:25:41Z: the node stage FAILED, exit 101 after 44 s: `kaspa-consensus` 96 passed, 1 failed, 3 ignored: `processes::finality::tests::ban_is_decided_by_the_carrying_block_so_nodes_agree_on_every_voter_list` panicked at `finality.rs:1883:82` (the `mine_on_all` helper's `validate_and_insert_block(...).unwrap()`) with `UnexpectedDifficulty(<bits>, 487112096, 487129281)`: a block built on one `TestConsensus` was refused by another for a difficulty a few hundred bits off. cargo stopped at the first failing package, so the other five were not run in this job. The app tests passed: `igneum-app` exit 0 in 6 s (hotplug's and elevated-exit's tests included; 96 + the signer and wrapper suites) |
|
||||
| fork 21d4c73c | PC 2 job `build-20261005-182804` (`--node-tests kaspa-consensus`, the suite ALONE) | 18:28:3x to 18:30:15Z: `kaspa-consensus` 97 passed, 0 failed, 3 ignored in 1.99 s, `ban_is_decided_by_the_carrying_block_so_nodes_agree_on_every_voter_list ... ok`. So the test passes alone (as in the c4 agent's own run, build-20261005-180827, 97 passed) and failed once under the six-package parallel run: the 0.3.6 isolation class (release-0.3.6 plan 8f), now with a difficulty expectation that depends on wall-clock timing rather than the PoW cache queue. Not a consensus regression by the same reasoning as then; recorded in section 11 for the c4 agent (the test or the helper should pin its clock) |
|
||||
| fork 21d4c73c | PC 2 job `build-20261005-183131` (`--node-tests "kaspa-consensus-core igneum-exec kaspa-pow igneum-miner kaspa-p2p-flows"`) | 18:31:31 to 18:33:59Z: exit 0 in 37 s: `kaspa-consensus-core` 101 passed (2 ignored), `igneum-exec` 15 passed, `igneum-miner` 15 passed, `kaspa-p2p-flows` 33 passed (the target compiles and passes on the merged tree: the nine `epoch_seed_headers` errors of release-0.3.6 are gone with tx-gossip's `ibd/proof.rs` update, as the c4 agent said), `kaspa-pow` 13 passed (both queue tests of housekeeping B). With `build-20261005-182804` (consensus alone, 97 passed) every package of the six is green on 21d4c73c; the app suite passed in `build-20261005-182244` |
|
||||
| tree 9a51e32+ (before pack-loop) | `proto-cuda/nvrtc/build-windows.sh` under the lock (mingw-w64 from Homebrew, the redist dir of the main checkout symlinked into the worktree: NVRTC 12.8.93 DLLs and headers, Khronos CL headers, nothing downloaded) | 19:17 to 19:18:17Z, a trial of the script before the pack-loop merge: igneum-worker-cuda.exe 196082e2... (1,509,376) and igneum-worker-opencl.exe 0981c77d... (444,928), both with the Igneum resource block (whose version string is 0.3.0: the `.rc` files are not among the six version files, section 11); the real pair is rebuilt from the final tree (section 5d) |
|
||||
|
||||
## 4. The state of the network before the cut
|
||||
|
||||
Both of the 0.3.9 agent's rollouts finished before anything of 0.3.10 moved: the fee switch (H = 210,000, every node on
|
||||
ab8847da..., `release-0.3.9.md` 10e, 17:55Z) and then N3 (Josh: "deploy N3 now"; `finality_v3_activation_daa` 135,200,
|
||||
manifest republished 17:59:14Z, `docs/plans/finality-v3-devnet-publish.md`): the sweep there lists every live node on
|
||||
`1f4b44255fcd2ea8f75664ed47200f409186ddd2292960c9e2cf95bbbdc11505` (the observer 17:58:13Z, node 1 17:58:25Z, the seed
|
||||
17:58:43Z, PC 2's app node 18:04:57Z, PC 1's app node 18:06:28Z). The override object every node runs, and the one the
|
||||
0.3.10 manifest and packaged line carry:
|
||||
|
||||
```
|
||||
{"difficulty_v2_activation_daa":33000,"proving_v0_activation_daa":84100,"fees_v1_activation_daa":210000,"finality_v3_activation_daa":135200}
|
||||
```
|
||||
|
||||
| Node | Binary at 18:03Z | Override | Digest |
|
||||
|---|---|---|---|
|
||||
| Mac d937c69d | app 0.3.9; its engine has run NO node of its own since its hourly restart at 17:45:16Z: it found node 1 answering on 127.0.0.1:26610 and uses it (the N3 record, "the Mac app's external-node finding"), so the Mac card's node line is node 1's | node 1's | node 1's |
|
||||
| PC 1 ae432dc7, PC 2 1ccfe586 | app 0.3.9, node a24ab01a (the Mac cross-build) | the four-field object | 1f4b4425... |
|
||||
| Sam's Mac 3a9bf309 | app 0.3.9, node a24ab01a, 0.0 MH/s at 18:03Z | (its app writes the manifest's object) | not read |
|
||||
| The observer, node 1 (hand nodes, this Mac) | `vendor/igneum-node-036/target-integration/release/igneumd` a24ab01a | `/tmp/igneum-devnet/override-v3.json`, the four-field object | 1f4b4425... (read 18:02Z) |
|
||||
| The seed 188.245.5.161 | `/opt/igneum/v4/bin/igneumd` 7e26e374... (the a24ab01a zig cross-build, glibc 2.34) | `/etc/igneum/override-v3.json`, the same | 1f4b4425... |
|
||||
| PC 37ba0461 | silent (0.3.7 at the 0.3.8 cut) | | |
|
||||
|
||||
Master moved under the branch while it was cut (fef98a2/a850aef CLAUDE.md, 900bba7 the site's 0.3.9 download cards, 46a6a4e and
|
||||
bf8d1ba the N3 record, a93199a); merged as d1f4923 (docs and site only, 5 files, no conflict). It moved again at 19:4xZ (the coordinator's
|
||||
`explorer` merge, origin/master 9746391: the observer's explorer detail, `site/api/stats.mjs` and `supply.mjs`, the explorer pages, three node
|
||||
tests and `tools/ci/public-api-check.mjs` in ci.yml; no app, node or packaging file): the final merge to master lands on it (section 7b).
|
||||
|
||||
## 5. The node binaries and the DMG (fork 21d4c73c, app 5520fb1 and later)
|
||||
|
||||
| Platform | Build | sha256 | Size | Notes |
|
||||
|---|---|---|---|---|
|
||||
| Mac arm64 igneumd | `CARGO_TARGET_DIR=vendor/igneum-node/target-0310 cargo build --release -j 4 -p kaspad -p igneum-miner --features kaspad/igneum-pow` from `vendor/igneum-node-0310`, under the lock, 18:21:54 to 18:24:2xZ (incremental) | 4bb356f492a52154c932ee6f802cd59dc5c917840ad46ba2133905f52b838b3c | 41,152,144 | `21d4c73c` in its strings; copied into `vendor/igneum-node-0310/target-integration/release/` |
|
||||
| Mac arm64 igneum-miner | same | 322472ae95a316d12281fed99b2112cdb5f7b6caf9f25ea24019219b7e907948 | 8,514,928 | byte-identical to the 2f88a82f build: the miner is untouched by gossip and c4 |
|
||||
| The digest check | that igneumd on a scratch node, ports 60975/60976, 22 s, 18:24:23Z, the four-field object | `Finality rule v3 ... 135200`, `Calibrated v1 fees ... 210000`, digest 1f4b44255fcd2ea8f75664ed47200f409186ddd2292960c9e2cf95bbbdc11505, `igneumd/2.1.0-21d4c73c` | | equal to every live node's (section 4): the three fork branches move no parameter; the chain script stops on any other value |
|
||||
| Linux x86-64 igneumd (the seed, glibc 2.36) | `NODE_SRC=<abs> TARGET_DIR=<abs> OUT_DIR=<abs> infra/cross/build-linux.sh` (zig), under the lock, 18:25:43 to 18:27:29Z (105 s incremental) | 40be0e142e30f6de89ccb61f6dc13e8748ed2a41988753604dbdbb9da1466b4b | 47,638,184 | `GLIBC_2.34` at most, `21d4c73c` in its strings; `version.txt` from 21d4c73c (release-0.3.10). Kept in the scratchpad `r0310/cross-final2/` and copied to `infra/cross/out-0310/` of the main checkout for `restart-seed.sh` |
|
||||
| Linux x86-64 igneum-miner | same | 6ec3536717536fd505b63eef5d01ec9fa19d1fe608ba86398135c5776222ec73 | 9,631,280 | |
|
||||
| Windows x86-64 igneumd.exe | PC 1 job `build-20261005-182405` (`IGNEUM_WIN_RELEASE=<fork>/target-integration/x86_64-pc-windows-gnu/release node tools/build-job.mjs run --node vendor/igneum-node-0310 --target ae432dc7 --targets linux,windows --no-tests` from this worktree: its own `build-inputs-20261005182334-74461.zip`, the per-job name of 68f14b9), published 18:24:05Z, started 18:26:1xZ after another agent's job on PC 1, every stage ok 18:31:43Z (linux 136 s, windows 162 s, pack 3 s; 9 files, 45 MB, all sha256 and PE checks ok, placed) | 3adb01a712fba678706ed5eeb1fa9788895735e4fab5e81dbefe03bd675a5e04 | 50,978,816 | the PC build (housekeeping A); no commit string inside (section 11); acceptance: section 5b |
|
||||
| Windows x86-64 igneum-miner.exe | same | 1105151c91738a4e177b8f327625c6e22a90423fe66a71a5d045130618281052 | 10,725,888 | |
|
||||
| Windows x86-64 igneum-app.exe (the PC's build; the installer's engine is the GitHub runner's) | same | 4ead820eb4c502de18f8c122d319543452615ba96b8471a501eb2237e76835b7 | 2,925,056 | hotplug's first Windows compile (`detect::adapters`, the WM_DEVICECHANGE path is `host.cpp`, built by the runner): warnings only; among them hotplug's own `function adapter_for is never used` (for its agent), the rest pre-existing (94 `trailing semicolon in macro`, p2p-flows 21, dead `keep`/`wsl_path`) |
|
||||
| Linux x86-64 igneumd (HiveOS, glibc 2.39, not the seed) | same, `infra/cross/out/` of this worktree | 38e69412b66a9ee183f3a28f25198f31ce29f83554f7a7f7dce9bdc4f61970be | 48,915,112 | |
|
||||
| Linux x86-64 igneum-miner, igneum-app | same | 39efcb1773dc215d..., cd8680e5b8c07795... | 9,607,448; 2,370,544 | |
|
||||
|
||||
### 5b. The acceptance of the PC-built Windows node (housekeeping A)
|
||||
|
||||
Run job `run-0310-accept-final` (the housekeeping agent's `run-accept-pc1.ps1` unchanged: copies the three exes from PC 1's
|
||||
`/root/igneum-build/target/x86_64-pc-windows-gnu/release` into a scratch folder with NO mingw DLL, runs `rocks-probe`, then
|
||||
`igneumd.exe --devnet --nodnsseed --disable-upnp --rpclisten=127.0.0.1:26710 --listen=127.0.0.1:26711 --nologfiles --yes` on a scratch
|
||||
appdir for 60 s, then `igneum-miner.exe key-hash probe`, then reads Application event 1000), published 18:32:37Z from this worktree:
|
||||
ran 18:33:08 to 18:34:15Z (67 s), exit 0: `rocks-probe` exit 0, `RESULT run node60: still running after 60 s (alive for the whole wait); killing it`, `igneum-miner.exe key-hash probe` exit 0, `RESULT event 1000: none since 18:32:11Z`. The same verdict as the housekeeping run (`run-hk-accept-1`) and my warm-up run on 2f88a82f (`run-0310-accept-hk`, 16:57Z, the exe 37d0b104...). So the PC-built Windows node ships; no Mac cross-build was needed. The payload inputs: section 5c.
|
||||
|
||||
### 5a. The DMG
|
||||
|
||||
The chain under the lock (the 0.3.9 script's shape): the app (`cargo build --release` in `app/igneum-app`, 18:24:47Z, target cloned from the
|
||||
0.3.9 worktree), the prover host and export (18:24:54Z, no Succinct toolchain: the host embeds `elf/`), `igneum-prove-host --mode id`
|
||||
on this tree: shard program id `0x2b1a81cb413236cf063077b46ed3111628f6c41036bcf6e23ee4cbbf5679ef7a` (2,832,504 bytes, sha256 0x150f4c05a2951fc5),
|
||||
aggregator `0x474678f35f7545db28055d5e5bbc308231d84a5a072202087a2a8d5b09123896`: the 0.3.9 pin, unchanged (no prover rollout). The
|
||||
nine real fixtures ran `--mode native` with the new host (338, 341, 344, 56 x2, 58927, 72803, 72854, 78: all ok); the first chain
|
||||
stopped on `block-72803-skipped-copies.json.node-plan.json`, which is a node-plan SIDECAR of the 72803 fixture (txgen 49796b0), not a
|
||||
fixture, so the glob now excludes `node-plan` sidecars. Then `NODE=<fork>/target-integration/release/igneumd MINER=... packaging/mac/build-dmg.sh`
|
||||
(18:26:04 to 18:26:34Z): `prover: ... from <worktree>/proving/igneum-prove/target/release`, the same `--mode id` line, fingerprints 477bb0ef
|
||||
(intake key) and ed9c4d2e (folder).
|
||||
|
||||
| Artefact | sha256 | Size |
|
||||
|---|---|---|
|
||||
| `packaging/mac/dist/Igneum-Miner-0.3.10.dmg` (engine 0.3.10, node 21d4c73c Mac arm64, the 0.3.9 prover host and export rebuilt from this tree) | e8f1f9f620cb598fd21ff2b6f6a2dea3d9e58efc0550c6454dc65f0a3d791b97 | 41,377,608 |
|
||||
|
||||
Read back from the DMG (mounted read-only, 18:28Z): `Contents/Resources/igneum-app.json` carries `node_override_params` =
|
||||
`{"difficulty_v2_activation_daa": 33000, "fees_v1_activation_daa": 210000, "finality_v3_activation_daa": 135200, "proving_v0_activation_daa": 84100}`
|
||||
(the packaged line of 9997ef1) beside `update_manifest`, `log_intake_url`, `log_intake_key`, `live_page`, `download_page`; `bin/` holds
|
||||
igneumd, igneum-miner, igneum-bench, igneum-prove-host, igneum-prove-export.
|
||||
|
||||
A note on my own logs: the chained scripts print `<time> ... exit $?` lines where the time is a command substitution evaluated first, so
|
||||
those lines always say 0. Every outcome in this plan is read from a content line (`Finished`, a sha256, a `RESULT`, a `final:`), never
|
||||
from them; `tools/lock/with-lock.sh` itself propagates the exit code (checked: `run bash -c 'exit 3'` returns 3).
|
||||
|
||||
## 6. The harness runs (C and D)
|
||||
|
||||
| Run | Node | Command | Result |
|
||||
|---|---|---|---|
|
||||
| D, early (before c4) | fork 21babaa7 Mac arm64 (`target-0310`) | `IGNEUMD=... IGNEUM_MINER=... IGNEUM_HARNESS_BASE_PORT=29750 IGNEUM_HARNESS_TMP=/tmp/igneum-txrelay-0310 tools/lock/with-lock.sh run node tools/txgen/relay-net.mjs --rate 2 --duration 120 --wallets 16 --fund 2` from the `tx-gossip` worktree (the harness was not yet on this branch) | 17:54:26 to 18:01:01Z: `final: sent 240, included 240, pending 0, failures 0`, 1.951/s, latency p50 2,017 ms, p90 3,561 ms, max 6,560 ms: the coordinator's expected 240 of 240 |
|
||||
| D, final | fork 21d4c73c Mac arm64 (igneumd 4bb356f4...) | the same command from the `tx-gossip` worktree (this worktree has no `node_modules`: the harness imports `viem`, the first attempt from here died at import; `tools/txgen/` needs an install note), ports 29750+, data `/tmp/igneum-txrelay-0310b` | 18:26 to 18:31:05Z: `final: sent 240, included 240, pending 0, failures 0`, 1.975/s, latency p50 1,537 ms, p90 3,027 ms, max 5,025 ms; report in the scratchpad `r0310/relay-final2/` (by miner: B and C only, A none, as the harness requires) |
|
||||
| C, first run | fork 21d4c73c Mac arm64 (igneumd 4bb356f4...) | `node tools/finality-attacks/c4.mjs on` with the script's DEFAULTS (split 150 s, weight window 120 DAA; ports 29900+, suffix 990, `/tmp/igneum-fin-c4-0310`), 18:24:50 to 18:34:43Z | `[FAIL]`: B locked 7 and 8 during the split, n0 reconnected 3 s after the heal, the three sinks DISAGREE, 0 locks adopted from an off-chain certificate, 9 / 4 / 4 conflicting certificates. The same shape as the bench-log's "on, split 150 s" row on the fix: a 150-s split is past the 120-DAA frozen table, so by the heal A's chain has outrun the table and the certified chain cannot be adopted; the c4 agent's measured PASS rows use `WINDOW=240` (a 140-s split stays inside the 126-DAA bound, as any partition under an hour does on the live devnet) |
|
||||
| C, final | the same binary | `WINDOW=240 SPLIT=140 node tools/finality-attacks/c4.mjs on` (the bench-log's PASS row's knobs, `/tmp/igneum-fin-c4-0310b`), 18:36:3x to 18:46:20Z | `[PASS] weight-vs-work-on`: B locked index 9 during the split (138 s after the cut), A's sink blue score 315 against B's 292 (A the heavier by work), n0 reconnected 3 s after the heal, the three sinks AGREE (0424542743) on B's chain, A's split tip abandoned, n0 adopted 1 lock from a certificate off its chain (the C4 fix) and re-determined 1 line, 0 conflicting certificates, 0 reorg lines, max locked index 15 on all three. The coordinator's rollout note holds on the shipped binary: the certified chain wins over the heavier one when every node is new |
|
||||
|
||||
### 5c. The Windows payload inputs (18:35:33 to 18:36Z)
|
||||
|
||||
`IGNEUM_WIN_RELEASE=<fork>/target-integration/x86_64-pc-windows-gnu/release IGNEUM_NODE_SRC=<fork> packaging/windows/push-inputs.sh` from this
|
||||
worktree (the `sed -n 1p` signer read of 68f14b9): igneumd.exe 3adb01a7... (50,978,816) and igneum-miner.exe 1105151c... (10,725,888), the PC 1
|
||||
build of 21d4c73c, with the PC's three mingw DLLs (libstdc++-6 26,347,027, libgcc_s_seh-1 774,200, libwinpthread-1 324,451; the exes import
|
||||
none of them since housekeeping A, the DLL gate and the copy stay for an older fork). Signed inputs: zip
|
||||
c175446e33ccb020e2f5fdaf99100d5bd46138c7693b3e16948a82f7c252eaa4 (33,996,479 bytes, 5 files), `node_source_commit`
|
||||
21d4c73c6ce32fcbd68391968e85827339a511c0 (release-0.3.10), `repo_commit` 9a51e32, built 18:35:37Z, deployed, `payload-inputs.json` HTTP 200;
|
||||
`node-source.pin` = 21d4c73c, committed as 065c67c.
|
||||
|
||||
## 7. The ship
|
||||
|
||||
`git push -u origin release-0.3.10` at b958493 (18:36:44Z, the credential helper; `gh auth switch --user igneum-josh` before every gh call,
|
||||
the login renamed igneum-labs at 18:00Z, the token still under that name), `gh workflow run windows.yml --ref release-0.3.10` -> run
|
||||
37357266092 (queued 18:36:49Z on b958493).
|
||||
|
||||
The pre-push hook (fb076de: no conflict markers, `cd site && node build.mjs`) left `site/index.html` and `site/journey.json` modified after the
|
||||
push. Measured with three builds of one tree at 18:38Z: the label of one bench entry ("RTX 5090 first run" against "RTX 5090, memory-hard
|
||||
dataset") ALTERNATES on every run, whatever the cwd: `build.mjs` reads `site/journey.json` back (line 248) and writes it, so each run
|
||||
transforms the previous output. Recorded in section 11. The hook's output (master's form) is committed as ccd2b98 and the next push's
|
||||
hook flipped it again; the tree is restored with `git checkout -- site/` after every push, so the ship's preflight sees it clean. Those commits touch no
|
||||
app or packaging file, so the installer built at b958493 is the release; the ship state file (`~/.cache/igneum/ship/0.3.10.json`) got `sha`
|
||||
= b958493 and `runId` = 37357266092, what the tool's own steps would have recorded, and the run resumes with `--from ci`.
|
||||
|
||||
Run 37357266092: green at 18:42:18Z (parse checks 52 s; engine, window host, payload, installer, smoke run 4 min 24 s; the G13 inputs step
|
||||
against the 21d4c73c inputs of 5c and the pin of 065c67c). The dry run of the ship command (18:38:54Z) read everything: tree ccd2b98 clean,
|
||||
0.3.10 in all 6, fork 21d4c73c, both exes, both Mac binaries, gh igneum-josh, live inputs 21d4c73c built 18:35:37Z, live manifest 0.3.9.
|
||||
|
||||
HOLD (coordinator, 18:4xZ): both PCs' NVIDIA workers have looped since 18:31Z (PC 1) and 18:35Z (PC 2) on `the epoch seed bytes do not give
|
||||
the pack's IGNEUM_SEEDW_INIT` (the exported GPU pack is the previous epoch's and the restart loop never re-exports it; the network fell to
|
||||
about 88 MH/s); the coordinator published re-export jobs and holds every rollout until the fleet mines again. The ship steps that deploy
|
||||
nothing ran by hand and the manifest step waits: `OTA_SKIP=1 CONSOLE_SKIP=1 packaging/windows/fetch-ci-artifacts.sh 37357266092` (18:43:26Z)
|
||||
and the DMG copied into the downloads folder.
|
||||
|
||||
| File (in the downloads folder, not yet deployed) | sha256 | Size |
|
||||
|---|---|---|
|
||||
| Igneum-Miner-0.3.10.dmg | e8f1f9f620cb598fd21ff2b6f6a2dea3d9e58efc0550c6454dc65f0a3d791b97 | 41,377,608 |
|
||||
| Igneum-Miner-Setup-0.3.10.exe | 7ecf109f3867b554ecb97be3542f23c8d76d9752d6bdc7ece7a6f95fc2f9bfd4 | 24,994,420 |
|
||||
| igneum-windows-app.zip | ebf0deca0e99de4085af1af5c3349028d8a8386f5016ee357ecf806df9a329c9 | 34,778,147 |
|
||||
|
||||
The installer is 5.2 MB larger than 0.3.9's (19,836,912) and the zip 7.1 MB larger: the PC's `libstdc++-6.dll` is 26,347,027 bytes
|
||||
(Ubuntu's GCC 13 build, unstripped) against the Mac toolchain's, and it rides in the payload although the exes import no mingw DLL since
|
||||
housekeeping A (section 11).
|
||||
|
||||
The ship command, to run when the hold lifts (every step before `manifest` skips itself):
|
||||
|
||||
```
|
||||
node tools/ship-app.mjs 0.3.10 --node vendor/igneum-node-0310 --branch release-0.3.10 --public \
|
||||
--activation-height 135200 --deadline-note "finality v3" --notes "<the five changelog lines, section 1; node 21d4c73c>" --from ci
|
||||
```
|
||||
|
||||
`consensus.override` is carried over from the folder's 0.3.9 manifest (the four-field object of section 4) and `--activation-height 135200
|
||||
--deadline-note "finality v3"` keep the consensus object identical field by field to the live one.
|
||||
|
||||
### 7a. The second final tree (Josh's scope change, 19:0xZ): the app rebuilt
|
||||
|
||||
The node stays 21d4c73c (its binaries, the digest, the suites, the harness runs and the Windows node acceptance all stand). What rebuilds:
|
||||
the app (G, H, I: the UI, the engine additions, the export lock), the two Windows WORKER exes (H changes `packfile.h`, `worker.cpp` and
|
||||
`host.c`), the payload inputs (now carrying the workers and the NVRTC DLLs), the DMG, the installer (CI), and the ship files.
|
||||
|
||||
| What | Result |
|
||||
|---|---|
|
||||
| The two Windows workers, `proto-cuda/nvrtc/build-windows.sh` under the lock, 19:24Z, from 854f9a8 | igneum-worker-cuda.exe 85cc357bb62bda36634ff4c1d80aca575aad71204ff2ef4bf620bdf183ab9236 (1,510,912), igneum-worker-opencl.exe afa73a324b9bfc3d957d0d5d3b6453770046721ac40cf7455ff3d49eb8b5774f (445,952); both differ from 0.3.9's (f50e19f2..., 1abc673e...) and from the hot-fix pair on the PCs (8dcef61c..., af7f12f5...), as the coordinator's check requires; the resource block on both |
|
||||
| `push-inputs.sh` again, 19:24:41Z | 12 files: the node pair and three DLLs of 5c, the two workers, nvrtc64_120_0.dll (86,728,192) and nvrtc-builtins64_128.dll (6,356,480), the three licence texts; zip ec1af603cb047471f8e6d6d95adbad192e0b77095e031fd4e39a807e37e8bc5c (71,940,402 bytes), node 21d4c73c, repo 854f9a8, signed, deployed, HTTP 200; the pin unchanged |
|
||||
| `cargo build --release` in `app/igneum-app`, then `cargo test --release -p igneum-app`, under the lock on this Mac (the coordinator's ask) | 19:24Z: ok, 103 (lib) + 27 (ota-sign) + 8 (prove-verify), 0 failed; `igneum-app 0.3.10` |
|
||||
| `--mode id` on the worktree's host (unchanged prover) | shard `0x2b1a81cb...`, aggregator `0x474678f3...` |
|
||||
| The DMG, `packaging/mac/build-dmg.sh` under the lock, 19:24:57 to 19:25:18Z | `Igneum-Miner-0.3.10.dmg` 151687c5672553c571af7224a8e4228348135b8a313fc89e6641db7ae21c85b3, 41,383,375 bytes (engine 0.3.10 with G, H, I; node 21d4c73c; the 0.3.9 prover), fingerprints 477bb0ef and ed9c4d2e; it replaces the e8f1f9f6... DMG of the first tree |
|
||||
| PC 1 app-only job (`node tools/build-job.mjs run --target ae432dc7 --targets windows --no-node --no-tests`, the branch's new `--no-node`) | published 19:24:50Z, done 19:26:47Z in 14 s (the engine alone, 6 s on PC 1's warm cache): igneum-app.exe 4564f8502bf40fdfb61a979ff19543838ca66be95b6b9f0beeaee53330c4d284 (2,962,944), warnings only (the dead-code set of 5a plus `count` is never used); `host.cpp` (WM_GETMINMAXINFO, WM_DEVICECHANGE) compiles on the runner only: CI run 37363381420 (section 7b) |
|
||||
|
||||
Not in this tree (they arrived after it; next cut): `job-console` 3562f26 (its second offer, 19:2xZ: power control as a setting, default off), and
|
||||
the FORK-side `pack-loop` 05ef0fa3 (`vendor/igneum-node`: the miner checks every pack and rebuilds a refused one, exit 44; a node change: the
|
||||
node stays 21d4c73c). The app side of af983a7 (`watchdog.rs` `PackRebuilds`, the engine re-exporting the pack on a refusal, capped per
|
||||
epoch) is keyed on the miner's exit 44, which only the fork-side miner emits (the pack-loop agent's correction, 19:3xZ): with the 21d4c73c miner a worker refusal line shows "program pack out of date, rebuilding" on the strip, the card and the log but does NOT re-export; with the attempt-aware workers of this release a refusal means a genuinely broken pack, so the gap is small, and the self-healing half lands with the next node cut (section 11).
|
||||
|
||||
### 7b. The second push and CI
|
||||
|
||||
`git push origin release-0.3.10` at 5b0d54f (19:26:13Z; the pre-push hook flipped the two site files again, restored with `git checkout -- site/`),
|
||||
`gh workflow run windows.yml --ref release-0.3.10` -> run 37363381420 (queued 19:26:19Z on 5b0d54f). The ship state file now names 5b0d54f
|
||||
and that run. At 19:46Z the run was still QUEUED with no runner assigned, as were the repo's two `ci` runs (19:26Z, 19:39Z): GitHub's status
|
||||
API reported Actions "degraded_performance" with an unresolved "Incident with Actions" (investigating since 19:15:17Z). Nothing in this
|
||||
repository or on this Mac can shorten that: the installer comes only from the hosted `windows-latest` runner. The rollout waits for the
|
||||
verdict; every other step is staged (the DMG, the signed inputs, the runbook `r0310/rollout.sh` with the exact commands).
|
||||
|
||||
Run 37363381420 ended at 19:41:24Z as `failure` with its first job CANCELLED by GitHub after 15 minutes queued ("The job was not acquired by
|
||||
Runner of type hosted even after multiple attempts", the job's annotation) and the build job skipped: nothing of the tree ran. The workflow
|
||||
was dispatched again under a watcher that dispatches again on that same annotation and stops on a green or on a failure of the tree itself:
|
||||
try 2 run 37365130137 (19:42:43Z), try 3 run 37366744501 (19:57:52Z), try 4 run 37368355454 (20:13:21Z), try 5 run 37369931084
|
||||
(20:28:32Z), every one cancelled by GitHub the same way after about 15 minutes queued; a follow-on watcher took over at 20:54:38Z and
|
||||
dispatched try 6, run 37374158235 (20:54:55Z), which a runner acquired at 21:24:04Z and which went green at 21:30:29Z (section 7d).
|
||||
Six dispatches, 2 h 04 min from the first to the green; GitHub's incident ("major outage" at 20:4xZ) was the whole of it.
|
||||
|
||||
### 7c. The fallback, prepared at 20:3xZ (the coordinator: a switch at 21:00Z, not a scramble; nothing built yet)
|
||||
|
||||
What the runner does for the installer, and what PC 1 has for each step (probe job `probe-installer-pc1-0310`, read-only, ran 20:33:5xZ
|
||||
in 3 s; its `R` helper collided with PowerShell's `r` alias so every line came back inside an error message, the facts intact):
|
||||
|
||||
| Runner step | Needs | PC 1 (ae432dc7) | Fallback |
|
||||
|---|---|---|---|
|
||||
| engine (`cargo build --release --locked`, MSVC target) | Rust on Windows | not probed (the PC's build jobs build the engine in WSL on the GNU target: igneum-app.exe 4564f850..., 5a) | the GNU-target engine from job `build-20261005-192450` unless `cargo` exists on the Windows side (checked at the start of job B); recorded either way |
|
||||
| packaged configuration | the intake key and the folder token as files | the installed 0.3.9 app's `igneum-app.json` at `C:\Users\Admin\AppData\Local\Programs\Igneum Miner\` carries the same manifest URL and key (`override=False`: 0.3.9 shipped no packaged override) | copy that file and add `node_override_params` = the four-field object (not a secret); no secret leaves the Mac |
|
||||
| payload inputs (`payload-inputs.zip`, signature, hashes, node commit) | the dl folder URL | the URL's folder is in the packaged json | download, verify the sha256s against `payload-inputs.json` (the signature is verified by the runner's step with the key compiled into the app; on the PC the app's own `igneum-ota-sign` is not installed: recorded as a gap, the hashes stand) |
|
||||
| window host (`app\windows\BUILD-APP.bat`, MSVC v143 cl.exe and rc.exe) | Visual Studio with MSVC | `vcvarsall.bat` under `C:\Program Files\Microsoft Visual Studio\...`, cl.exe 19.51.36260 | runs as on the runner |
|
||||
| payload (`make-payload.sh`, bash) | Git Bash | `C:\Program Files\Git\bin\bash.exe` (and WSL) | runs as on the runner |
|
||||
| installer (`build-installer.ps1`: Inno Setup 6 `ISCC.exe`, rcedit) | Inno Setup 6, rcedit-x64 | ISCC NOT FOUND; rcedit not on PATH; winget present | `build-installer.ps1` installs Inno Setup 6 through winget (a tool install on PC 1: the coordinator's call) and downloads rcedit-x64 from GitHub (`-NoRcedit` skips it: the exes then ship without the coin icon and version block, cosmetic, recorded) |
|
||||
| smoke run, launcher dry run | the exes | | the same PowerShell lines in job B |
|
||||
| the files back to the Mac | | `relay/clients/send.ps1` (a file up to 50 MB straight to Blob): the installer is 25 MB, the payload zip 35 MB | two `send.ps1 <file>` calls with the sha256s in the report; on the Mac `node tools/relay.mjs read <id>`, sha256 compared, `packaging/windows/check-runtime-dlls.sh` on the payload folder |
|
||||
| code signing | none on the runner either | | none |
|
||||
|
||||
The jobs, in order (neither published until the word; both staged in the scratchpad `r0310/fb/`: the tree zip `fb-tree-0310.zip`, 709,814 bytes,
|
||||
98 files from 5b0d54f by `git archive`, sha256 012c8a52c27631e8539704d703bf13eb68de0afd9cef094e68d1e6fc398f2cfc; the script `fb-installer-pc1.ps1`,
|
||||
which stops on any sha mismatch, pins the inputs' node commit against `node-source.pin` as the runner's G13 step does, and takes `-NoRcedit` as its one argument):
|
||||
|
||||
1. `fetch` job `fb-tree-0310` to ae432dc7: a zip of the tree at 5b0d54f (`git archive`: `app/windows`, `brand/icons`, `packaging/windows`,
|
||||
`proto-cuda/windows-app`, `proto-cuda/{host.cu,build.bat,README.md}`, `proto-opencl/{host.c,build.bat,README.md}`, `wsl2`, the two
|
||||
worker `.rc` files), `--dir jobs --extract --extract-dir fb-0310 --fresh`.
|
||||
2. `run` job `fb-installer-0310` to ae432dc7 (PowerShell, 20 min): `cargo --version` if any; the packaged json copied and extended; the
|
||||
inputs zip downloaded and hash-checked; `BUILD-APP.bat`; `make-payload.sh` under Git Bash with `IGNEUM_APP_EXE` = the WSL engine
|
||||
(`\\wsl$\Ubuntu-24.04\root\igneum-build\...\igneum-app.exe`, its sha256 checked against 4564f850...); `build-installer.ps1 -Payload <folder>
|
||||
-Version 0.3.10` (with or without `-NoRcedit` per the word); `igneum-app.exe --version`, `Igneum Miner.exe --version`; `send.ps1` the
|
||||
installer and the zip; every version and sha256 in the report.
|
||||
3. On the Mac: the two files into the downloads folder, hashes equal to the report, the DLL gate, then
|
||||
`node tools/ship-app.mjs 0.3.10 ... --from dmg` (preflight, then dmg already, copy, manifest, deploy, verify, console; the fetch step is
|
||||
skipped by `--from`, the console item carries no run id), then the rollout as planned.
|
||||
|
||||
What the gate records in this plan if the fallback ships: the installer marked "PC-built on ae432dc7, GitHub run owed"; non-reproducible
|
||||
(the runner's engine is the MSVC target, this one the GNU target from WSL; Inno Setup's version from winget; cl.exe 19.51.36260; Git Bash's
|
||||
version; Windows 11 build 26200); the sha256 and size of the engine, the host, the payload zip and the installer; the GitHub run's id and its
|
||||
own installer sha256 when it lands (they differ by construction); and the next cut goes back through the runner.
|
||||
|
||||
The coordinator asked at 21:3xZ whether the Mac mingw path was now faster and safer; the answer stands as below (there is no such path to an
|
||||
installer), so PC 1's queue position was kept for attempt 4.
|
||||
|
||||
What does not exist: a Mac mingw build of `host.cpp` (the coordinator's "how 0.3.7 shipped"): 0.3.7's node exes were cross-built on the Mac
|
||||
and its installer still came from the runner (release-0.3.6 plan, section 9); the host has only ever been built by `BUILD-APP.bat` with MSVC,
|
||||
on the runner or on a PC. So if PC 1 lacked MSVC the next fallback would be new work, not a known path; PC 1 has MSVC, so it is not needed.
|
||||
|
||||
### 7d. The fallback, run (the coordinator's word at 21:00Z: GitHub's status page "Actions: major outage", the sixth queued run)
|
||||
|
||||
The PC 1 window: asked of the Counter ASIC coordinator (it schedules PC 1 tonight) at 21:0xZ; granted at 21:09:24Z when its reproducible
|
||||
benchmark released the machine (every card restored; the RX 9070 XT back on the bus). `fb-tree-0310` (fetch) published 21:10:59Z, ran
|
||||
21:11:32 to 21:11:33Z: the zip (709,814 bytes, sha256 ok) extracted into `%LOCALAPPDATA%\igneum\app\jobs\fb-tree-0310\fb-0310` (the
|
||||
script's expected path was wrong and it now finds the tree by search). `fb-installer-pc1` (run, 25 min cap) published 21:19:28Z, FAILED at 21:20:05Z with exit 2 after 2 s: the script, not the build.
|
||||
Its own lines: the tree found (82 files), `cargo on Windows: none` (so the engine is the WSL GNU-target build), Git Bash 5.3.15, Windows
|
||||
10.0.26200, and then `engine not found at \\wsl$\Ubuntu-24.04\root\igneum-build\...\igneum-app.exe` with PowerShell's
|
||||
`ItemExistsUnauthorizedAccessError`: the WSL tree belongs to root and the `\\wsl$` share refuses it to the app's non-elevated session. The
|
||||
build jobs read that tree with `wsl -u root`, so the script now copies the engine out with
|
||||
`wsl.exe -d Ubuntu-24.04 -u root -- bash -c "cp ... /mnt/c/.../fb-0310-work/igneum-app.exe"`. Republished as `fb-installer-pc1-2` at 21:2xZ
|
||||
(the Counter ASIC coordinator kept PC 1 for it: "a 2-second exit 2 is the script, not the build", 5-minute reply window met).
|
||||
|
||||
`fb-installer-pc1-2` (21:23:08Z) failed at 21:23:58Z, exit 2 in 4 s: `/root/igneum-build/app/igneum-app/target/...` does not exist (the build
|
||||
job keeps ONE target dir for the whole job; the acceptance script had read the node exes from `/root/igneum-build/target/...`): the script
|
||||
now finds `igneum-app.exe` under `/root/igneum-build` with `find` and prints its sha256 from inside WSL. `fb-installer-pc1-3` (21:26:52Z)
|
||||
failed at 21:27:20Z, exit 2 in 4 s: the inline `bash -c "..."` string lost a quote on its way through PowerShell (`unexpected EOF while
|
||||
looking for matching quote`), the class the 0.3.6 cut closed for the app's own WSL calls (3811d8e, "every WSL script runs from a file") and
|
||||
which this scratch script had reopened: the bash part is now written to `engine.sh` on the PC (LF, no BOM, the acceptance script's own
|
||||
pattern) and run as `bash <file> <arg>`. Three 4-second failures of the script, none of the build; by the Counter ASIC coordinator's rule
|
||||
its 10-minute measurement takes PC 1 first, then a read-only path probe (every path the script needs, found and printed), then attempt 4.
|
||||
|
||||
Before attempt 4 one more change, after reading the fixed block: `find ... | tail -1` would take the NEWEST `igneum-app.exe` under
|
||||
`/root/igneum-build`, and other agents' build jobs have run on PC 1 since mine (job-console's, pack-loop's), so the sha check could stop
|
||||
attempt 4 on another tree's engine. The engine this plan already holds and verified (4564f850..., 2,962,944 bytes, from PC 1's own job
|
||||
`build-20261005-192450`) now travels INSIDE the tree zip (`fb-tree-0310b.zip`, with it under `app/igneum-app/target/x86_64-pc-windows-gnu/release/`),
|
||||
and the script reads nothing from WSL at all; the path probe checks that file too.
|
||||
|
||||
Attempt 4 was never published: at 21:30:29Z GitHub's runner acquired try 6 (run 37374158235, dispatched 21:10:04Z on 5b0d54f) and it went
|
||||
GREEN (the parse job 21:24:04 to 21:24:57Z; engine, window host, payload, installer, smoke run 21:25:18 to 21:30:29Z; the G13 inputs step
|
||||
against the 21d4c73c inputs of 7a). The recipe's own installer therefore ships; the fallback stops here with nothing built on PC 1, PC 1
|
||||
released to the Counter ASIC coordinator at 21:31Z, and the prepared pieces (the tree zip with the engine, the installer script, the two
|
||||
probes, the runbook steps) kept in the scratchpad `r0310/fb/` as the rehearsed path for the next outage. Cost of the detour: three
|
||||
4-second script failures on PC 1 and about 70 minutes of the shipper's attention; the gate record "PC-built, non-reproducible, GitHub run
|
||||
owed" is not needed.
|
||||
|
||||
### 7e. The ship (21:31 to 21:40Z)
|
||||
|
||||
`OTA_SKIP=1 CONSOLE_SKIP=1 packaging/windows/fetch-ci-artifacts.sh 37374158235` (21:31:21Z): the installer and the payload zip into the
|
||||
downloads folder; the DMG copied beside them. The payload zip holds the rebuilt workers (igneum-worker-cuda.exe 85cc357b..., 1,510,912;
|
||||
igneum-worker-opencl.exe afa73a32..., 445,952: both differ from 0.3.9's f50e19f2.../1abc673e... and from the PCs' hot-fix pair), the PC-built
|
||||
node 3adb01a7..., and the runner's MSVC engine 496c4883... (3,361,792). The first ship run (21:32:02Z) stopped in preflight: the tree was 4
|
||||
commits behind origin/master (the coordinator's explorer merge, 9746391: docs, site, observer, ci.yml; no app, node or packaging file), so
|
||||
`origin/master` was merged as ff873f6 (37 files, no conflict), pushed 21:32:27Z (the hook's site flip restored), and the state file kept
|
||||
`sha` = 5b0d54f, the CI commit, as the 0.3.6 and 0.3.9 cuts did.
|
||||
|
||||
```
|
||||
node tools/ship-app.mjs 0.3.10 --node vendor/igneum-node-0310 --branch release-0.3.10 --public \
|
||||
--activation-height 135200 --deadline-note "finality v3" --notes "<the seven changelog lines; node 21d4c73c>" --from ci
|
||||
```
|
||||
|
||||
| Step | Result |
|
||||
|---|---|
|
||||
| preflight | ok: tree ff873f6 clean, 0.3.10 in all 6, fork 21d4c73c, gh igneum-josh, live inputs 21d4c73c built 19:24:41Z |
|
||||
| ci | already: run 37374158235 green |
|
||||
| fetch, dmg, copy | already (above) |
|
||||
| manifest | 0.3.10 mac+windows, signed (key 8f186e37...), verified locally; `consensus` carried over from the folder's 0.3.9 manifest: `activation_height` 135200, `deadline_note` "finality v3", `override` the four-field object; and in `dl/public/` (with the wallet 0.1.4 manifest) |
|
||||
| deploy | one deploy, 21:33Z |
|
||||
| verify | the token folder: `igneum-app-latest.json` 0.3.10, signature ok, mac 151687c5..., windows 24e58849...; the public folder: every file HEAD 200 with the local size (the DMG, the installer, the 0.3.9 HiveOS package, the wallet DMG, the four `/public/` aliases, the two manifests and their signatures, `igneum-downloads.json`), but the byte comparison of a json file against the edge still failed after 12 tries at 21:37Z and again on a resume from `verify`: the edge cache, the class of the 0.3.6 and 0.3.9 verifies (section 11 if it does not clear) |
|
||||
| console | (pending: `--from console` after the verify clears) |
|
||||
|
||||
| File | sha256 | Size |
|
||||
|---|---|---|
|
||||
| Igneum-Miner-0.3.10.dmg | 151687c5672553c571af7224a8e4228348135b8a313fc89e6641db7ae21c85b3 | 41,383,375 |
|
||||
| Igneum-Miner-Setup-0.3.10.exe | 24e58849f2b517e8c5579455e8c9d8376ff7dd27052f458ef91913cbc48abc88 | 49,858,273 |
|
||||
| igneum-windows-app.zip | 16b68b7bf625a946ac62a22413982b38c6649a124d799b08424a070f1b34810e | 71,809,486 |
|
||||
|
||||
The installer is 30 MB larger than 0.3.9's (19,836,912): the two NVRTC DLLs (93 MB unpacked) ride with the rebuilt CUDA worker now.
|
||||
|
||||
`update-now-0310` to all, published 21:39:59Z (apps woken, stamp a23f594a). Timing with the Counter ASIC coordinator (it schedules PC 1 tonight): PC 1
|
||||
was released by its hot-table job at 21:35:39Z, so one update-now went to every machine; its era measurement starts on PC 1's 0.3.10 STATUS line.
|
||||
|
||||
## 8. The machines after the publish (manifest live 21:33Z, update-now 21:39:59Z)
|
||||
|
||||
Baseline 21:40:31Z: Mac d937c69d app 0.3.9 (its card reads node 1, a24ab01a), PC 1 ae432dc7 0.3.9 node a24ab01a DAA 133,903 141.5 MH/s,
|
||||
PC 2 1ccfe586 0.3.9 node a24ab01a DAA 133,945 0.0 MH/s (its 5090 worker off since a job's `/api/resume` at 21:25:11Z that the 0.3.9 app
|
||||
answered and never acted on, section 11), Sam's Mac 3a9bf309 quit 53 min earlier, PC 37ba0461 0.3.9 node `2.1.0` 2.0 MH/s. A machine
|
||||
counts as updated when its engine logs the 0.3.10 header, its node reports a DAA score and its miner a hash rate on 0.3.10. The two checks
|
||||
asked by the coordinator: (1) the workers start without the seed-words error on the first try, on the rebuilt pair; (2) the Mac's card
|
||||
shows node 1's digest, by design. C5: the provers' "stopped after" lines (shards aborted by the restart).
|
||||
|
||||
| Machine | On 0.3.10 | Its log |
|
||||
|---|---|---|
|
||||
| PC 1 ae432dc7 (Windows) | engine restart 21:40:41Z (run `win-ae432dc7-20261005-214041`), 42 s after the job; `[ok] updated to Igneum Miner 0.3.10 from 0.3.9` 21:40:42Z; `igneumd started` 21:40:46Z (the PC-built node from the new install path); `node proof verifier: command` and `reported: command` +6 s; `cards: NVIDIA GeForce RTX 5090 [discrete, off] \| AMD Radeon(TM) Graphics [integrated, off] \| AMD Radeon RX 9070 XT [discrete, off]` (hotplug's line: the iGPU integrated and off, the two discrete cards then started); miners started 21:40:47Z (nvidia-ae432dc7-1, the bundled `igneum-worker-cuda.exe`) and 21:40:48Z (amd-ae432dc7-3, the bundled OpenCL worker); `update to 0.3.10 complete` 21:42:12Z. Check 1 PASS: the NVIDIA miner's `epoch seed 66b26013... (daa 133967): CPU program and cache ready in 176 ms` 21:40:47Z, then `worker: info first pack packs\devnet: nvrtc 170 cache 4 dataset 23 check 249 race 37802 ms variant base; self-test PASS` and `worker: ready cuda NVIDIA_GeForce_RTX_5090 ... prepare 1 path nvrtc 12.8` at 21:41:25Z, no `epoch seed bytes do not give` line; STATUS 45.1 MH/s wall at 60 s (124.1 inside jobs), 124.3 MH/s inside jobs from 90 s on; the console 141.4 MH/s at 21:45:17Z (both cards). The node log: `igneumd/2.1.0` (no commit: the PC build, section 11), `Calibrated v1 fees ... 210000`, digest 1f4b44255fcd2ea8f75664ed47200f409186ddd2292960c9e2cf95bbbdc11505, peers node 1 (192.168.68.64) outbound and inbound and the seed 188.245.5.161, flows registered at THEIR protocol version 13 (a v14 node beside v13 peers, the mixed-fleet case of section 10, measured) | |
|
||||
| Mac d937c69d | engine restart 21:40:51Z (run `mac-d937c69d-20261005-214051`), 52 s after the job; `[ok] updated to Igneum Miner 0.3.10 from 0.3.9` 21:40:52Z; `a node already answers on 127.0.0.1:26610; using it (it is not stopped by this app)`: the app attaches to node 1 as it has since 17:45Z, so its card shows node 1's version and digest (check 2, by design); `cards: Apple M5 Max [apple, off]` (its miner was off before the update too); `update to 0.3.10 complete` 21:42:22Z | |
|
||||
| PC 2 1ccfe586 (Windows) | its 0.3.9 app fetched the woken jobs file at 21:40:33Z (`1 new for this machine, 1 queued`) and queued the update behind the aggregation-cost agent's running job `agg-cost-pc2-2` (one job at a time), so the update ran at 21:49:24Z when that job ended: installer downloaded and verified 21:49:27Z, `quit: stopping the miners, then the node` 21:49:29Z (no prover "stopped after" line: no shard in flight), engine restart 21:49:41Z (run `win-1ccfe586-20261005-214940`), 9 min 41 s after the job; `igneumd started` 21:49:42Z; `cards: NVIDIA GeForce RTX 5090 [discrete, off] \| AMD Radeon(TM) Graphics [integrated, off]`; miners started 21:49:44Z. Check 1 PASS: `epoch seed 66b26013... (daa 134395): CPU program and cache ready in 169 ms`, `worker: ready cuda NVIDIA_GeForce_RTX_5090 ... first pack ... self-test PASS` at 21:50:21Z, no seed-words line; STATUS 120.7 MH/s inside jobs at 60 s, 120.5 at 90 s; the console 123.2 MH/s at 21:53:04Z. This also ended the `/api/resume` no-op (its 5090 had been off since 21:25:11Z). The node log: `igneumd/2.1.0`, digest 1f4b4425..., flows at version 13 with node 1 and the seed (still a24ab01a for 10 s more) and at 14 with PC 1. The prover: `prover: host /opt/igneum/igneum-prove-host (WSL2), CUDA`, the pinned ids, then three assigned shards (22126, 51922, 84099) each failed with `Failed to create the CUDA prover impl: CudaClientError: Connect(Os { code: 13, kind: PermissionDenied })` at 21:49:56, 21:50:12 and 21:50:32Z: PC 2's prover is dark after the update (section 11) | |
|
||||
| PC 37ba0461 (Windows, the US laptop) | its 0.3.9 app (run `win-37ba0461-20261005-202247`) downloaded and verified the installer at 21:40:52Z, started it at 21:40:53Z (`per-user install, no administrator prompt`), stopped its miners and node at 21:41:16Z, and no 0.3.10 engine run had reported by 21:56Z (the console: `STOPPED (update)` for 14 min): the install is in progress or stuck on the owner's machine; nothing to drive from here (section 11) | |
|
||||
| Sam's Mac 3a9bf309 | quit since 20:47Z (0.3.9); takes 0.3.10 when it is started | |
|
||||
|
||||
C5, the shards aborted by the restart: PC 2's engine logged no prover "stopped after" line at its quit (21:49:29Z) and PC 1 runs no prover;
|
||||
the Mac's prover was off. Observed count: 0. The coverage numbers measured across the restart carry no abort from it.
|
||||
|
||||
The ship's last step, `--from console` (21:55:08Z): item #364 "Igneum Miner 0.3.10 shipped (mac+windows)"; the tool's closing line
|
||||
"manifest 0.3.10 published 2026-10-05T21:32:40Z".
|
||||
|
||||
## 8. The machines after the publish
|
||||
|
||||
(pending)
|
||||
|
||||
## 9. The hand nodes and the seed (21:49 to 21:50Z)
|
||||
|
||||
Moved while PC 2 and PC 37ba0461 were still on 0.3.9 (their updates gated by another agent's job and a slow download): the digest does
|
||||
not change with 0.3.10 and a v14 node beside v13 peers is the measured mixed-fleet case (section 10), so moving them shortened the mixed
|
||||
window.
|
||||
|
||||
| Node | Command | Result |
|
||||
|---|---|---|
|
||||
| The observer, then node 1 | `IGNEUMD=<fork>/target-integration/release/igneumd IGNEUMD_COMMIT=21d4c73c infra/devnet/restart-hand-nodes.sh '<the four-field object>'` (the branch's script: `grep -c` for the commit string, the object written to `/tmp/igneum-devnet/override-v3.json`, the previous file kept with a stamp) | observer restarted 21:49:38Z (pid 67770), node 1 21:49:50Z (pid 67963, caffeinate 67965); both print `Calibrated v1 fees ... from DAA score 210000` and digest 1f4b44255fcd2ea8f75664ed47200f409186ddd2292960c9e2cf95bbbdc11505; the Mac app's card follows node 1 from here (its node line reads 21d4c73c) |
|
||||
| The seed 188.245.5.161 | `IGNEUMD_LINUX=infra/cross/out-0310/igneumd IGNEUMD_LINUX_SHA256=40be0e14... infra/devnet/restart-seed.sh '<the same object>'` (the zig cross-build of 21d4c73c, glibc 2.36 target; the script checks the sha on both sides, keeps the previous binary as `igneumd.prev-035` and the previous override file with a stamp) | restart 21:50:15Z, unit active, MainPID 125195, `igneumd/2.1.0-21d4c73c`, `Calibrated v1 fees ... 210000`, digest 1f4b4425... |
|
||||
|
||||
Peers after the restarts: the observer and node 1 connected to the seed within 20 s and register flows at protocol version 14 with each
|
||||
other and the seed (node 1's inbound from the observer 21:50:08Z, node 1 to the seed 21:50:20Z, the observer to the seed 21:50:38Z; the
|
||||
seed's three inbound peers from this Mac's address at 21:50:20, 21:50:27 and 21:50:38Z, all at 14). PC 1's node (started 21:40:46Z, before
|
||||
the hand nodes moved) registered flows at version 13 with node 1 and the seed (then a24ab01a) and keeps them; PC 2's node (21:49:42Z) at 13
|
||||
with node 1 and the seed (10 s before their restarts) and at 14 with PC 1: the mixed fleet of section 10, measured on the live network, with
|
||||
no refusal and no drop.
|
||||
|
||||
### 9a. The digest sweep (21:50Z)
|
||||
|
||||
| Node | Binary | Digest | Read from |
|
||||
|---|---|---|---|
|
||||
| PC 1 app node ae432dc7 | the PC-built 3adb01a7... (`igneumd/2.1.0`, no commit string) | 1f4b44255fcd2ea8f75664ed47200f409186ddd2292960c9e2cf95bbbdc11505 | its node log through the log intake (run 214041) |
|
||||
| PC 2 app node 1ccfe586 | the same | 1f4b4425... | its node log (run 214940) |
|
||||
| The Mac app d937c69d | node 1's (attached; its card's commit string is the app's own reading from its start at 21:40:51Z, before node 1 restarted, and refreshes on the app's schedule; its status line reads node 1's height and peers) | node 1's | |
|
||||
| Node 1 | 21d4c73c Mac arm64 4bb356f4... | 1f4b4425... | `/tmp/igneum-devnet/node1.out` |
|
||||
| The observer | the same | 1f4b4425... | `/tmp/igneum-devnet/observer-v4.out` |
|
||||
| The seed | 21d4c73c Linux 40be0e14... | 1f4b4425... | the journal through `restart-seed.sh` |
|
||||
| PC 37ba0461 | (its update in progress) | (pending) | |
|
||||
| Sam's Mac 3a9bf309 | quit since 20:47Z | (pending) | |
|
||||
|
||||
One digest, equal to the N3 publish's (`finality-v3-devnet-publish.md`) and to this cut's three readings on the fork (section 3): 0.3.10
|
||||
moved no parameter, as measured.
|
||||
|
||||
## 10. The mixed fleet during the window
|
||||
|
||||
Between the manifest publish and the last app's update, old (a24ab01a) and new (21d4c73c) nodes share the devnet. What the two
|
||||
agents' reports say about whether they can disagree:
|
||||
|
||||
| Change | Old and new nodes together | Source |
|
||||
|---|---|---|
|
||||
| D, transaction relay (PROTOCOL_VERSION 13 to 14) | They connect: a v14 node sends the three new messages only to peers at version 14 or later; a 13 peer never sees them (a node drops a connection on an unknown payload, which is why the version moved). Blocks, headers and the handshake are unchanged, the digest is unchanged. Only the mempools differ: a transaction sent to an old node is included only by that node's own templates, as before; a new node's pool converges with other new nodes'. No disagreement about the chain | the tx-gossip commit message e242acd0, the coordinator's line |
|
||||
| C, the certificate-driven reorg | A consensus-behaviour change with no digest change: an old node keeps the shipped behaviour (a certificate over a block off its chain stays pending and it never reorgs to it), a new node locks that block and moves to the heaviest tip through it. The two CAN disagree on the selected tip after a partition heals while the fleet is mixed (exactly the C4 shape); the c4 agent's rollout note: it converges when every node is new. On the devnet tonight there is no partition in progress and one network, so the window carries no live split; the sweep in section 9 checks every node's DAA within a few blocks of the others | the c4 agent's rollout note (coordinator, 18:2xZ), the bench-log C4 rows |
|
||||
| A, B, E, F | Windows link settings, a test switch, the app's card handling and job exit codes: nothing on the wire | |
|
||||
| The override object | The same four fields on every node before and after; the manifest carries it verbatim (section 7), so no node's digest moves | sections 4 and 7 |
|
||||
|
||||
So the window is safe for ordering (no digest change, no protocol break) and the only behavioural difference needs a partition to show;
|
||||
the hand nodes and the seed move last (section 9), after every app node is on 21d4c73c, so the fleet is fully new within minutes of the
|
||||
publish.
|
||||
|
||||
## 11. Open after the cut
|
||||
|
||||
The next cut (0.3.11), decided by the coordinator on 5 October 2026 night:
|
||||
|
||||
| Branch | What | Why not 0.3.10 |
|
||||
|---|---|---|
|
||||
| fork `pack-loop` 05ef0fa3 (`vendor/igneum-node`) | `write_pack_checked`, `export-pack` exit 3, the force-prepare at the epoch boundary, the miner's exit 44 that unlocks the app's capped re-export | a node change after the node was frozen at 21d4c73c with its suites, harness runs and Windows acceptance done; the app side (af983a7) with the attempt-aware workers is the fix that matters tonight and it is in |
|
||||
| `job-console` 13755b9, then 3562f26 and whatever follows | one hidden-console builder for every elevated launch, the spawn check in CI, PC 1 console watchers; then power control as a setting, default off (Josh: "if we don't have to ask then don't ask") | arrived after the tree closed (both times); repoints elevated-exit's `include_str!` test at `platform.rs` at its own merge |
|
||||
| `opencl-rdna4-telemetry` 7adcd4c (`igneum-wt-rdna4-telemetry`, not pushed: master + a08c371 as 38c9eec + 7adcd4c) | `igneum-gpu-telemetry.exe` (ADLX, SetupAPI bus, PDH fallback; amdgpu sysfs on Linux) built by `build-windows.sh`, shipped by `make-payload.sh` and `push-inputs.sh`; the engine runs it at `-l 5` and fills power, temperature, fan, memory clock and utilisation on the AMD card; 82 app tests pass. The 9070 XT measured on PC 1: 198.9 W, 64 C, 17.73 MH/s, 0.089 MH/W against the 5090's 0.398 MH/W (job `tele-measure-1`) | arrived after the tree closed; a new shipped exe and an engine source |
|
||||
| `ember-tune` 54ff1bc (+38ef711, `igneum-wt-ember-tune`; its agent's message at 21:2xZ) | Ember Tune: `ember.rs` replaces the NVIDIA power-only run in `sweep.rs` and the AMD single-lever sweep of 720b369; sits on cherry-picks of 7adcd4c and 13755b9+3562f26, so those merge first; 93 app tests, `relay/test/ember.test.mjs` and `ui/tune-line.test.mjs` in ci.yml; design in `docs/plans/ember-tune.md` | arrived after the tree closed |
|
||||
| the rest of `opencl-rdna4` a08c371 | the OpenCL worker's duplicate-platform fold, `--readback select`, `--memprobe`, `detect.rs parse_opencl_list`, the 9070 XT bench-log entry | conflicts with gpu-hotplug in `detect.rs` and `host.c`; only its EXPORT_LOCK hunk shipped (854f9a8) |
|
||||
|
||||
| Item | State |
|
||||
|---|---|
|
||||
| The per-job build-inputs zip (68f14b9, c4's tooling commit; the coordinator's check after two agents' PC jobs ran on another agent's sources through the shared `build-inputs.zip` tonight, jobs build-20261005-191656 and -192537). Checked on this tree's own jobs in the live jobs file rather than a dry run (a dry publish would leave a stray entry in the file the ship deploys): `build-20261005-182405` (PC 1) pins `params.zip_url` = `.../build-inputs-20261005182334-74461.zip`, `params.sha256` 628e6dae..., size 8,266,818; `build-20261005-183131` (PC 2) pins `build-inputs-20261005183051-83796.zip`, c83ebb2a..., 7,271,492; both shas equal the local zips of those names, and 15 per-job zips sit beside the folder default. So a job published through `tools/build-job.mjs` pins the zip it just pushed. The residual: `packaging/ota/publish-jobs.sh add --kind build` WITHOUT `--zip` still defaults to the shared `$DEST/build-inputs.zip` (line 307); nothing refuses that name. A hand-added build job can therefore still pin whatever the folder default holds | build-job.mjs path closed; the hand path is the first item of the next cut: refuse `--kind build` without `--zip`, or default to the newest per-job zip |
|
||||
| `infra/cross/build-linux.sh` resolved a relative `TARGET_DIR` inside the node source after its `cd`, so the copy step shipped the previous build's bytes (twice tonight, caught by the commit string in `strings`). Fixed here (e0a5fd1). The class: any script that takes a directory argument and changes directory before using it; `proto-cuda/windows-node/cross-build.sh` takes the node worktree as `$1` and `CARGO_TARGET_DIR` from the environment (the 0.3.9 cut passed a relative one and it worked only because that script does not cd); a CI check for the shape is owed | fixed on the branch; the check owed |
|
||||
| hotplug's `proto-opencl/host.c` change (the worker's `--list` prints the PCI address, the key to one row per physical card): no prebuilt OpenCL worker is in the payload inputs (the live zip holds igneumd.exe, igneum-miner.exe and three DLLs), the payload carries `host.c` and `build.bat` and the app builds the worker on the PC from them (`engine.rs build_worker_from_source`), so the change ships inside the 0.3.10 installer; whether an app that already has a worker exe rebuilds it from the newer source was not read here. Check on the console after the update: the PCs' card rows carry the PCI address (and one row per AMD card) only if the worker was rebuilt; else a rebuild is the hotplug agent's follow-up | open: told the hotplug agent |
|
||||
| `kaspa-consensus` test `ban_is_decided_by_the_carrying_block_so_nodes_agree_on_every_voter_list` (c4-fix) failed once under the six-package parallel run on PC 2 with `UnexpectedDifficulty(487112096 vs 487129281)` in `mine_on_all` (section 3) and passes alone, twice. The helper builds a block on one `TestConsensus` and inserts it into others; the expected difficulty of the receiving node differs when the run is slow, which points at a wall-clock dependence (difficulty v2's sanitised clock per header). For the c4 agent: pin the clock or the timestamps in that helper, as the finality tests do for the cache queue. Until then a parallel six-package run can fail this test under load; the suites are run as consensus alone plus the other five | open |
|
||||
| `site/build.mjs` is not idempotent: one bench entry's label alternates between "RTX 5090 first run" and "RTX 5090, memory-hard dataset" on every run of the same tree (three runs at 18:38Z: first-run, memory-hard, first-run), because the build reads its own `site/journey.json` back (line 248) and the label table at lines 203 and 204 matches against what the previous run wrote. Every push flips the two files through the pre-push hook, which is why the 0.3.9 and 0.3.10 cuts both met a modified tree after the push | open: build the journey from the sources only (never from the previous output), then have the hook refuse a push whose build differs from the committed site |
|
||||
| The Windows payload carries the PC's three mingw DLLs (34 MB of inputs, `libstdc++-6.dll` alone 26.3 MB unstripped) although the exes import none of them since housekeeping A; the installer grew 5.2 MB | open: drop the DLLs from `push-inputs.sh` and `jobbuild.rs`'s copy once no shipped fork needs them, or strip them |
|
||||
| The two worker exes' version blocks say 0.3.0 (`proto-cuda/nvrtc/igneum-worker-cuda.rc`, `igneum-worker-opencl.rc` are not among the six files `ship-app.mjs` bumps) | open: add the two `.rc` files to `VERSION_FILES` |
|
||||
| The app's re-export of a refused pack (af983a7's `watchdog.rs` `PackRebuilds`, 3 per epoch) waits for the miner's exit 44, which the 21d4c73c miner never emits: a refusal in 0.3.10 shows the notice and restarts the worker, no re-export. The fork-side `pack-loop` 05ef0fa3 (the miner checks every pack, rebuilds a refused one, exit 44) is the other half | next node cut |
|
||||
| C1 (the consequences reviewer, 20:0xZ): the 0.3.10 node's `igneum_exportSegments` (fork `igneum/exec/src/rpc.rs`) writes no `daaScore` and no `feesV1ActivationDaa` per segment, which the 0.3.9 exporter needs to replay both sides of the fee switch (`export/src/main.rs`: "a dump without `daaScore` is accepted only when the switch is never or 0"). Measured here: the handler (90 lines from `rpc.rs` 849 in `vendor/igneum-node-0310`) carries neither key (`daaScore` appears in the fork only in other RPCs, lines 239, 423, 819); the reviewer names `vendor/igneum-node-pv1` at eb32c645 (on 21d4c73c) as the carrier: its `rpc.rs` writes `daaScore` per segment (line 961) and `fees` and `feesV1ActivationDaa` at the top level (line 982); confirmed here at 20:4xZ (`git -C vendor/igneum-node-pv1 grep -n feesV1ActivationDaa -- igneum/exec/src/rpc.rs`: line 982); my first read of that path at 20:1xZ was wrong. So at H = 210,000 (about 19:50Z on 6 October) every 0.3.10 prover's export of a post-H block fails or meters with the wrong table and the fleet's provers go dark, unless the next node cut carries that RPC onto every prover before H, or H is republished later. The mechanism (the proving agent, 20:1xZ): the app's prover calls the exporter with no fee flags, so from H every app prover on 0.3.10 cuts with the prototype table and every statement is vetoed. The two closes: (a) the proving-v1 fork (eb32c645, on 21d4c73c) on every prover before H through 0.3.11; (b) republish H = tip + 86,400 by the fee-switch plan's rule. Decision due 16:00Z on 6 October; the coordinator, the proving agent and the 0.3.11 shipper hold the same line | open, dated: (a) or (b) by 16:00Z on 6 October |
|
||||
| C5 (the same reviewer): the app kills its prover child on quit (`prover.rs` 266 to 272), so the `update-now` of this rollout aborts whichever shard each prover has in flight (up to 37 s each, no payout). Accepted as the cost of the restart; the count is read after the rollout from the provers' "stopped after" lines (section 8) so the coverage numbers measured across the restart are read with it | recorded at the rollout |
|
||||
| `/api/resume` answered ok on the 0.3.9 app and never restarted the miners (PC 2's 5090 worker "off" with the card holding 1.7 GB from a job's resume at 21:25:11Z until its 0.3.10 restart, the iGPU miner too; the Counter ASIC coordinator, 21:4xZ). The class: a resume that reports success without a miner restart. The app should re-check the miner processes after a resume and report a failure | open: next cut |
|
||||
| PC 2's prover after the 0.3.10 restart: every assigned shard fails at once with `CudaClientError: Connect(PermissionDenied)` (section 8), where 0.3.9's PC 2 proved shards from 17:36Z. The host, the pinned ids and the CUDA backend are detected as before; the connect that fails is the SP1 CUDA prover's client socket. The aggregation-cost agent's job `agg-cost-pc2-2` ran as root in WSL minutes before and its lines show no CUDA or socket change; the 0.3.10 app's prover path changed only to read the program ids (`--mode id`). Unexplained; handed to the coordinator and the Counter ASIC coordinator (PC 2's measurement agents) | open: PC 2 proves nothing until it is found; a restart of the WSL distro or of the CUDA prover service is the first thing to try |
|
||||
| PC 37ba0461 (the US laptop) started the 0.3.10 install at 21:40:53Z, stopped its miners and node at 21:41:16Z and had not come back by 21:56Z: the per-user installer on the owner's machine, nothing to drive remotely | open: the console shows when it returns; its 0.3.10 line and worker start are read then |
|
||||
| `dl/public/igneum-downloads.json` (the unsigned index the site's download page reads) alternates at the edge between the new bytes and the previous ones for over 20 minutes after the deploy (one fetch byte-identical at 21:52Z, the next three not): different edge nodes behind one hostname. The two signed manifests were byte-identical and verified from the first check. The ship's verify step counts it as a failure and refuses to post the console item, so the item was posted with `--from console` | open: the verify should accept the index after the signed manifests pass, or retry it for longer; the site serves the previous version's buttons from a stale edge until it settles |
|
||||
| The console's machine card keeps a machine's LAST non-empty node commit string: PC 1's card read `node 2.1.0-a24ab01a` for 17 minutes after its node had restarted as the PC build (`igneumd/2.1.0`, no commit), while PC 2's card read `2.1.0` at once; the Mac's card kept node 1's a24ab01a after node 1 moved to 21d4c73c. A card's commit string is therefore not a fact about the running node until the app re-reads it; the node log is | open: the card should show the string the app last READ, with its time, or nothing |
|
||||
| The site build, run on a tree with conflict markers in `site/journey.json`, fails silently and leaves the markers (it reads that file back, the non-idempotence above): the first merge commit of this cut to master carried markers in two site files for one minute and was amended (the pre-push hook would have refused it, but the check must not depend on the hook) | fixed by hand here; the build should refuse a journey.json that does not parse and say so |
|
||||
| The PC-built node binaries embed no commit (the build inputs zip has no git dir, `build-info` falls back to the bare version): the console shows PC 1 and PC 2 as node `2.1.0` with no commit after 0.3.10, as the 0.3.6 PC build did. The build inputs manifest records the fork commit (2f88a82f and later) | open: a commit stamp through the build job (`jobbuild.rs`) is an app change, not tonight |
|
||||
|
|
@ -32,7 +32,7 @@ All in public, on the hashrate charts. 51% never reaches 2/3 while honest miners
|
|||
- **C1.** Checkpoint i is the selected-chain block at blue score 30 i. It is determined once the virtual's blue score reaches 30 i + d. d = 60 at 1 block per second is a placeholder (ledger F7): the gate 3 devnet records the reorg-depth distribution and sets d so that a vote split at one index is rare and self-heals at the next. d scales with block rate. Re-determination (rule of 4 October 2026, night, ledger F24): while index i is not locked, a node whose selected chain moves past C_i (a reorg deeper than d) determines index i again on its new chain; its own votes for the old block stand (a key never signs two blocks at one index) and a certificate the network formed over the new block, received meanwhile and held pending, is then verified. A locked index is never re-determined (3.11.4): fork choice keeps the chain through its block, and a certificate for another block there is a conflict (C4). A lock lands about 90 to 120 s after a transaction (Designed; simulated lock latency after the checkpoint block is median 2.5 s, p99 4.6 s at a 2-s inter-region delay, `sim/results_v2.md` A).
|
||||
- **C2.** A vote is a BLS signature over `(chain_id, i, hash(C_i))` under a fixed domain-separation tag. Votes gossip as their own message type.
|
||||
- **C3.** A lock certificate for index i is an aggregate BLS signature over one checkpoint block hash with a bitmap of signers, whose signed weight meets Q3. Every block carries the highest certificate its producer knows. A block whose selected chain does not pass through every certified checkpoint in its past is invalid (section 2.4).
|
||||
- **C4.** A node holding a LOCK at index i rejects any other certificate for index i and publishes the pair as evidence (section 3.6). A certificate over a block that is not the node's own determination at an index it has not locked is not a conflict: the chain may still move to that block (C1 re-determination, ledger F24), so the node keeps it pending, bounded, until it does or the index is left behind. A certificate naming a block that cannot be index i's checkpoint on any chain (its blue score is under 30 i, or its selected parent's is not) is refused outright.
|
||||
- **C4.** A node holding a LOCK at index i rejects any other certificate for index i and publishes the pair as evidence (section 3.6). A certificate over a block that is not the node's own determination at an index it has not locked is not a conflict: the node verifies it at that block and, when it is valid there, locks the index on it and moves its chain (3.5, the certificate-driven reorg, rule of 5 October 2026 night, ledger C4). A block the node does not have yet is kept pending, bounded, and tried again as the DAG arrives (ledger F24). A certificate naming a block that cannot be index i's checkpoint on any chain (its blue score is under 30 i, or its selected parent's is not) is refused outright.
|
||||
- **C5.** No certificate may form in the chain's first 3,600 DAA seconds (design document). See 3.8 for the proposed first-month rule.
|
||||
|
||||
## 3.3 Quorum
|
||||
|
|
@ -114,6 +114,8 @@ The honest level is therefore the number of indices at which k actually voted an
|
|||
- **F4.** There is no hidden-block penalty in consensus. It was removed in review round 2 because it breaks DAG determinism and amplifies eclipse attacks. First-seen MAY break ties in a node's own block template only.
|
||||
- **F5.** A node started with a configured trusted certificate follows it. A node started cold selects the DAG with the most accumulated blue work, then follows certificates found in it. A private DAG that out-works the public one over the window is a public 51% event lasting weeks.
|
||||
|
||||
**Certificate-driven reorg (rule of 5 October 2026, night; ledger C4; design document Finality v2, Fork choice items 1 to 4).** A node that holds a valid certificate for a checkpoint block that is not on its selected chain MUST move its virtual to the heaviest tip through that block. Valid means: the block is index i's checkpoint on its own chain (C1), it lies on the chain through every lock the node holds, the certificate verifies against the canonical voter list at that block (C3), and its signers meet Q3 and, under rule v3, Q5 there, every input a function of the block's own past. The node records the lock at i on that block, replaces its own record there, and determines its unlocked records again on the new chain (C1 re-determination). Merge depth does not bound this move. Finality outranks merge depth by design (item 2 above: a certified checkpoint removes other tips from candidacy, blue work decides only among candidates), and the certified chain's blocks are valid under their own merge-depth roots. The depth-based finality point does bound it, as F1 already implies: a certified block that is not in the future of the node's depth-based finality point is beyond what any rule can follow (section 2's pruning safety) and needs the operator's trusted certificate (F5). A certificate for a chain that misses a lock the node holds, at any index, is a conflict under 3.11.4: the lock stands and the pair is reported. While a node holds locks, its own determination at a higher index locks only on the chain through them. Before this rule the node held such a certificate pending a reorg that GHOSTDAG alone never produced, and a 96-s honest partition with no attacker ended in a permanent finality fork (bench-log "FUD ledger sweep round 6", C4; the fix and its measurement under "the C4 fix", 5 October 2026 night).
|
||||
|
||||
What a node does when it holds two valid certificates at one index after a partition heals is not modelled and not defined (`sim/results_v2.md`, "cannot tell us"; O-3.6). C4 says it publishes the pair. The proposal for gate 3: both certificates are evidence against every key that signed both; the node re-evaluates both against Q3 with those keys' weight struck, and if exactly one still locks it follows that one; if neither or both still lock, F2 decides among the two checkpoint blocks' descendants and the index is treated as uncertified.
|
||||
|
||||
## 3.6 Equivocation evidence
|
||||
|
|
@ -170,7 +172,7 @@ Status of this section: Implemented in `vendor/igneum-node` (reading guide in `d
|
|||
| C1 | Checkpoint i is the lowest selected-chain block with blue score at least 30 i (blue scores along the chain can skip values), determined when the sink's blue score reaches 30 i + d, d = 20 on devnet. Re-determination (branch `fud-consensus`, 4 October 2026 night, ledger F24): after every virtual change, every unlocked record whose block is no longer a chain ancestor of the sink is determined again on the new chain (`on_virtual_changed`, "re-determined" log line); the certificate held over the old block is dropped, the fold clock restarts, and the certificates kept pending over the new block (`pending_certificates`, at most 4 per index, indices up to 64 ahead of the next determination) are verified. A locked record is never revisited. Unit test `reorg_past_an_unlocked_checkpoint_re_determines_it_and_verifies_the_pending_certificate` (a 6-block side chain's certificate is pending with no conflict, the 15-block side chain overtakes, index 13 is re-determined and locks from it; a block with the wrong blue score is refused) and `a_locked_checkpoint_pins_the_chain_and_a_certificate_against_it_conflicts` (a side chain twice as long does not become the sink past a lock, the certificate against the lock is the one conflict) | d = 20 is below the placeholder 60; the devnet reorg-depth distribution that sets d has not been recorded. Measured in `docs/bench-log.md`, "round-4 consensus items" (reorg run) |
|
||||
| C2 | BLS signature over `"igneum-vote-v1/" \|\| chain_id \|\| 0 \|\| index \|\| hash(C_i)` under `IGNEUM_VOTE_V1_BLS12381G2_XMD:SHA-256_SSWU_RO_NUL_`; the chain id is the prefixed network name (`igneum-devnet`, `igneum-devnet-7`); votes are p2p message 70 and ride in the coinbase extra data of every block | |
|
||||
| C3 | Certificate = index, checkpoint, voter count, signer bitmap over the canonical voter list (keys above dust and not stripped, sorted by key hash), aggregate signature, aggregator key hash and sortition proof. Every template carries the certificates not yet in its past | The validity rule (a block whose selected chain misses a certified checkpoint is invalid) is NOT enforced; only fork choice (F1, F2) is |
|
||||
| C4 | A certificate at an index for a block other than the one LOCKED there is kept and logged as CONFLICTING (`conflicting_certificates`); at an unlocked index it is held pending (F24 above), not logged as a conflict | Not published as evidence. The rule is now fixed by 3.11 item 4 (the node keeps the certificate it verified first, never re-evaluates it, and reports the conflict); the node does not yet clear `finality_active` or expose `finality_conflict` when the pair appears. Until 4 October 2026 night a reorg deeper than d made the node log every certificate at the moved index as CONFLICTING (ledger F24) |
|
||||
| C4 | A certificate at an index for a block other than the one LOCKED there is kept and logged as CONFLICTING (`conflicting_certificates`), as is one whose block is not on the chain through the node's nearest locks at any index; at an unlocked index over a block this node holds it goes through the certificate-driven reorg (`ingest_off_chain`, 5 October 2026 night, ledger C4: verified against `voters_at` of that block, Q3 and Q5 by `quorum_at` from the block's own past, then LOCKED there, "LOCKED by certificate" log line, and the virtual processor is asked to resolve again, `VirtualStateProcessingMessage::Resolve`); over a block this node does not have it is held pending (F24 above) and tried again on every virtual change | Not published as evidence. The rule is now fixed by 3.11 item 4 (the node keeps the certificate it verified first, never re-evaluates it, and reports the conflict); the node does not yet clear `finality_active` or expose `finality_conflict` when the pair appears. Until 4 October 2026 night a reorg deeper than d made the node log every certificate at the moved index as CONFLICTING (ledger F24) |
|
||||
| C5, 3.8 | `min_daa` = `weight_window` (2,592,000 DAA s on mainnet, 7,200 on devnet; a unit test pins the equality). `evaluate` never locks, and `ingest_certificate` refuses a certificate from any source, while the checkpoint's DAA score is below `min_daa`; the node logs "finality not active, window filling, N of M" at every determination until the sink's DAA score reaches `min_daa` and reports the same through `getFinalityCheckpoints` (`finality_reason`, `window_filled_daa`, `window_full_daa`). Unit test `processes::finality::tests::no_certificate_while_the_window_is_filling`: one key holding 100% of the weight signs every checkpoint of a 150-block chain at a 60-DAA window; nothing certifies below DAA 60, a hand-built certificate at an early index is refused, every checkpoint from DAA 60 locks (fin-fixes, 4 October 2026) | Implemented on 3.8's recommendation ahead of the launch-month simulation (O-3.1), which is still not run; gate 3 can lower the gate but not remove it without reopening ledger F1. The sink's DAA score the report compares is the one the virtual processor last handed the manager, so a restarted node reports the window as filling until its first virtual resolution |
|
||||
| Q1, Q2 | Presence window 20 indices on devnet (240 mainnet). Block reading: participation counts the indices in `[i - P, i - 1]` at which a vote by the key is carried by any block, blue or red, in the past of C_i; a key whose first block in the window is younger than P x 30 DAA seconds counts the full window; every template carries up to 48 votes not already in its past, certificates and evidence first | The per-block vote bound (48) is the devnet value of O-3.3. Participation is credited for any vote by the key at the index, whatever block it names; 3.11.1 requires the vote to name the checkpoint on the crediting chain, else a key can stay in the active denominator by voting for blocks of its own and never add to a certificate (O-3.19) |
|
||||
| Q3 | Integer tests: `3 x signed x P >= 2 x active_num` (active_num = sum of weight x participation count) and `3 x signed >= 2 x total` (was `30 x signed >= 17 x total` until 4 October 2026; `FinalityParams::FLOOR_NUM / FLOOR_DEN` = 2/3 on branch `devnet-v4`, with `quorum_met`, `floor_met` and `locks` as pure functions), both inclusive, both at C_i; bans known at evaluation time are applied to the voter list. Unit test `floor_is_two_thirds_of_total_and_inclusive`: 4 of 6 locks, 3 of 6 does not, 67 of 100 locks, 66 does not, the total test implies the active test for every participation. Measured on the three-node, six-voter network of `docs/bench-log.md`, "finality floor 2/3" (4 October 2026): no lock on either side of a 3/3 split, the 4 side of a 4/2 split locks at exactly two thirds | |
|
||||
|
|
@ -178,13 +180,13 @@ Status of this section: Implemented in `vendor/igneum-node` (reading guide in `d
|
|||
| Q5 | Rule v3 (same branch and switch): `frozen_table` finds the highest locked index below i whose block is an ancestor of C_i (`state.locks`, reachability), takes `voters_at` of that block with the bans known now, and drops it when `daa(C_i) >= daa(C_f) + weight_window`; `evaluate` requires `floor_met(frozen_signed, frozen.total)` of the signers (and of a held certificate's signers) on top of Q3; a locked checkpoint is never downgraded. The LOCKED log line carries the frozen fraction and the frozen lock's index; a checkpoint that passes Q3 and fails Q5 logs "held by the frozen table" at debug. Unit test `frozen_table_holds_a_side_without_the_other_keys_for_one_window`: A at 60% and B at 40% lock together; B leaves; under v2 A locks alone within 30 DAA of B's last block, under v3 not before the last lock is one window old, and then it does | The reference is the node's own highest lock on the chain (not the certificate carried in C_i's past), so a node that has not seen the newest certificate tests against the previous lock's table, which in a connected network differs by 30 s of blocks |
|
||||
| S1 | VRF output = SHA-256 of the voter's BLS signature over `"igneum-sortition-v1/" \|\| chain_id \|\| 0 \|\| index \|\| hash` under the sortition tag (unique per key and message, so the signature is the proof); eligible when `output x total_weight < 8 x weight x 2^64`, drawn by weight (W6, ledger F17, fin-fixes 4 October 2026): the expected number of aggregators is 8 by weight whatever the key count, a key with no weight never draws, a key holding 1/8 of total weight or more always does (so with 8 or fewer equal voters everyone is eligible). Unit test `sortition_is_by_weight_not_key_count`: 200 dust keys draw nothing, 6 real keys draw `sum min(1, 8 w / T)`, 16 equal keys draw 8.00, a key split into 10 or 200 parts draws what it drew whole | Was `output x voters < 8 x 2^64` (per key) until 4 October 2026; measured on the attack harness (`docs/bench-log.md`, "finality v2 attack harness" S2, then "finality fixes F17 and F1"). A key above 1/8 of total weight that splits itself gains seats (its single ticket was capped at 1); seats carry no reward and no power, since anyone MAY aggregate and Q3 is tested by weight |
|
||||
| S2 | Not implemented (sub-user sortition above 8,192 voters) | |
|
||||
| F1, F2 | In `resolve_virtual` the highest locked checkpoint that is in the future of the depth-based finality point and in the past of some body tip replaces the finality point: tips outside its future are not sink candidates | A lock that no body tip passes through is logged and ignored for that resolution |
|
||||
| F1, F2 | In `resolve_virtual` the highest locked checkpoint that is in the future of the depth-based finality point and in the past of some body tip replaces the finality point: tips outside its future are not sink candidates. Since 5 October 2026 night (ledger C4) a lock can be a block off the node's selected chain, adopted from a certificate (`ingest_off_chain`), so this is the certificate-driven reorg: the sink search keeps only tips through it, whatever the blue work of the old chain and whatever the merge depth; `evaluate` locks the node's own determination only on the chain through its nearest locks (`off_lock_chain`) | A lock that no body tip passes through is logged and ignored for that resolution; a lock not in the future of the depth-based finality point (a certified chain deeper than the finality depth) is logged once and cannot be followed (F5) |
|
||||
| F3 | Not implemented: the pruning point and `virtual_finality_point` ignore locks | Must land before any pruning network |
|
||||
| F5 | Not implemented (trusted certificate at start) | |
|
||||
| 3.6 | A second vote by one key at one index for another block is evidence, carried in blocks (`EvidenceRecord`: the two votes, the carriers with their DAA scores). Branch `fud-consensus` (4 October 2026 night, ledger F23): the ban at checkpoint C is computed from C's own past (`bans_at`): the key is stripped at C when a carrier lies in C's past and `daa(C) < daa(lowest carrier) + ban` (7,200 DAA seconds on devnet); detection over RPC or gossip only puts the evidence into this node's templates ("detected here: carried in this node's next block"). Evidence records are bounded (4,096, the oldest dropped; a record goes once its ban ended two windows below the sink or it was never carried within one ban of being seen; 16 carriers per record). Unit test `ban_is_decided_by_the_carrying_block_so_nodes_agree_on_every_voter_list`: three nodes on one chain, one takes the equivocating vote over RPC, two see it from the carrier block only; the voter list agrees on all three at every checkpoint, the key is a voter before the carrier and after the ban and nowhere in between, and the third node verifies the first two's certificates at every locked index | A carrier the reachability store no longer holds (pruned) counts as in the past of every checkpoint more than the merge depth younger than it. Until 4 October 2026 night the ban was stamped node-locally (ledger F23). Measured in `docs/bench-log.md`, "round-4 consensus items" (ban run) |
|
||||
| 3.9 | `getFinalityCheckpoints` reports `finality_active` (the window is full and a lock exists within the last P indices), the latest lock, and since 4 October 2026 `finality_reason` (`active`, `window filling, N of M` with N the sink's DAA score capped at `min_daa` and M `min_daa`, or `paused`) with `window_filled_daa` and `window_full_daa`; the miner prints a `FINALITY` line whenever the reason changes | `last_certified` as a DAA score is not reported; the conflict reason of 3.11 item 4 (two certificates at one index) is not reported (O-3.17), so a conflict still reads as `active` or `paused` |
|
||||
| 3.11 item 6 (seed source) | The devnet keys the hourly program on the header's own `daa_score` (`epoch_seed`, `docs/review/round-3-2026-10-03.md`, R3.26), not on a checkpoint block | The `seed_source` rule (section 4.3 with the uncertified fallback of 3.11 item 6) is not implemented; nothing on the devnet exercises a seed during a finality pause |
|
||||
| 3.11 test table | The four-miner test network of the bench-log entry is the only measurement on a real DAG: 93 checkpoints, 0 conflicting certificates, one equivocation strip, one 12-checkpoint pause under the floor, one heal | d = 20, presence 20 indices and a 7,200-s window are devnet values; the measured pause and heal are at those values, not the mainnet ones |
|
||||
| 3.11 test table | The four-miner test network of the bench-log entry is the only measurement on a real DAG: 93 checkpoints, 0 conflicting certificates, one equivocation strip, one 12-checkpoint pause under the floor, one heal; since 5 October 2026 night the fast-time harness row for the certificate-driven reorg (3.11.7) | d = 20, presence 20 indices and a 7,200-s window are devnet values; the measured pause and heal are at those values, not the mainnet ones |
|
||||
|
||||
Node state is one persisted blob (`DatabaseStorePrefixes::IgneumFinality`), written at most once a second; votes received over RPC but not yet carried by a block are lost on restart, votes in blocks are not.
|
||||
|
||||
|
|
@ -280,6 +282,7 @@ Each guarantee, the scenario that tests it, and the measured result. Bench-log c
|
|||
| Acquired keys decay as the window moves (3.11.5) | results K, keys worth 20% and 40% bought, 30% hashrate, signing and silent, 30 days, five seeds | share follows b (1 - t/30) + 0.3 t/30 within 0.6 points at every sampled day in every seed; keys worth 20% rise to 30% on day 30 and never reach 1/3; keys worth 40% hold the veto from day 1 to day 19 or 20 (formula 20) and end at 30%; withholding its votes, the 40% buyer stalls 63,307 to 68,716 of 86,400 checkpoints in 30 days (the pause lasts until it has decayed below one third) and the 20% buyer 304 to 1,045; 0 conflicts (K, floor 2/3) |
|
||||
| Signing stops while mining continues, 1, 6, 24 h | results J and L1 | 34% and above: every checkpoint stalled for the whole silence; 33%: 665 to 727 of 720 in 6 h; 32%: 35 to 221; 30%: 0 to 40; first lock after resume 0 min at every weight; 0 conflicts (J, L1) |
|
||||
| Seeds during a pause (3.11.6) | devnet epoch boundary through a forced pause | not yet run (O-4.3 implementation) |
|
||||
| A certificate over a chain the node is not on: the certificate-driven reorg (3.5) | fast-time harness `tools/finality-attacks/c4.mjs`, weight against work, 130-s split, 240-DAA window, rule v2 (the live devnet's) | the work-majority node fetched the certified chain, locked 12, 13 and 14 by certificate within 2 s of the first block, re-determined 11, and all three nodes ended on the certified chain with 0 conflicting certificates and 0 disagreeing locks; the same under rule v3 with a 140-s split: the certifying side locked 11 and 12 during the split, the work-majority node adopted 12 by certificate 3 s after the heal and every node ended on the certified chain, 0 conflicting, 0 disagreeing; the module-off control took the heavier chain (bench-log "the C4 fix", 5 October 2026 night) |
|
||||
| Two certificates at one index: no lock withdrawn (3.11.4) | devnet with a forced double certificate | not yet run (O-3.17) |
|
||||
| `T` under the block reading (3.11.3) | O-3.3 re-run | not yet run (O-3.18) |
|
||||
| Participation credited only for the chain's checkpoint (3.11.1) | results C with an adversary voting for private blocks | not yet run (O-3.19) |
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ An item closes when its measurement is in `docs/bench-log.md` or its decision is
|
|||
| O-3.3 | Parameters of the block reading of participation (rule closed 3 October 2026, section 3.3 Q2 and 3.4.1; ledger F3): the per-block vote bound and the carriage window, and the simulation ran with the narrower cert reading | Add vote carriage in blocks and a hostile aggregator to `finality_v2.py`; re-run A, C, D and F1 under the block reading; set the per-block vote bound and confirm the carriage window of 240 indices | 3 |
|
||||
| O-3.4 | Certificate grace value; must be at least 3x the worst honest one-way delay (section 3.3, Q4) | Measure one-way delays on the devnet across regions; set grace | 3 |
|
||||
| O-3.5 | VRF construction for aggregator selection and the binomial sub-user sortition above 8,192 voters (S1, S2) | Specify (candidate: BLS-based VRF on the vote key, Algorand's binomial sampling); simulate the threshold on sampled weight | 3 |
|
||||
| O-3.6 | What a node does with two valid certificates at one index after a partition heals; post-heal fork choice is unmodelled (`sim/results_v2.md`, "cannot tell us") | Adopt or replace the proposal in section 3.5; devnet partition-and-heal test | 3 |
|
||||
| O-3.6 | What a node does with two valid certificates at one index after a partition heals; post-heal fork choice is unmodelled (`sim/results_v2.md`, "cannot tell us"). Narrowed 5 October 2026 night (ledger C4): post-heal fork choice for ONE certified chain is now the certificate-driven reorg of 3.5, implemented and measured on the fast-time harness (`tools/finality-attacks/c4.mjs`, bench-log "the C4 fix"); what is left is the two-certificate case, O-3.17 | Adopt or replace the proposal in section 3.5; devnet partition-and-heal test | 3 |
|
||||
| O-3.7 | The eclipse case is closed by the quorum floor (section 3.3.2, 3 October 2026; ledger F2): 0 conflicting locks at 1, 2 and 4 h against a 34% attacker in the model, but the model grants the attacker the eclipse for free | Devnet with a single-node eclipse recording whether conflicting locks appear, as confirmation of the rule; no rule choice remains | 3 |
|
||||
| O-3.8 | The simulation has no DAG: conflict counts are index collisions; red blocks, merge under the 3,600-s bound and the finality overlay's effect on GHOSTDAG's guarantees are unmodelled (ledger C4, F8) | Devnet runs with the finality module on and off; a churn and adversary simulation driven by real pool-hashrate traces from mid-cap GPU coins (design document, "Three experiments") | 3 |
|
||||
| O-3.9 | Model assumptions that move the numbers: perfect or instant DAA retarget (real lag of the order of an hour, approximate), uptime 97% / 99.5% is a guess, silent sets random by key not by pool or region, keys are free, VRF noise absent (`sim/results.md` and `results_v2.md`) | Re-run `finality_v2.py` with a DAA lag model, a top-pool silent set and a regional silent set; price keys through the P2P layer | 3 |
|
||||
|
|
|
|||
|
|
@ -17,6 +17,11 @@ REPO="$(cd "$HERE/../.." && pwd)"
|
|||
NODE_SRC="${NODE_SRC:-$REPO/vendor/igneum-node-v4}"
|
||||
TARGET_DIR="${TARGET_DIR:-$REPO/vendor/igneum-node/target-linux}"
|
||||
OUT_DIR="${OUT_DIR:-$HERE/out}"
|
||||
# Every path absolute before the cd into NODE_SRC (5 October 2026, the 0.3.10 cut: a relative TARGET_DIR was resolved by
|
||||
# cargo inside the node worktree, the copy below read the repository-relative one, and the seed's "new" binary was the
|
||||
# previous build's bytes, twice).
|
||||
abs() { case "$1" in /*) printf '%s' "$1" ;; *) printf '%s/%s' "$PWD" "$1" ;; esac; }
|
||||
NODE_SRC="$(abs "$NODE_SRC")"; TARGET_DIR="$(abs "$TARGET_DIR")"; OUT_DIR="$(abs "$OUT_DIR")"
|
||||
TARGET="${TARGET:-x86_64-unknown-linux-gnu}"
|
||||
GLIBC="${GLIBC:-2.36}"
|
||||
JOBS="${JOBS:-4}"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<key>CFBundleVersion</key>
|
||||
<string>VERSION_STAMP</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.9</string>
|
||||
<string>0.3.10</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ DL_HOST="https://dl.igneum.network"
|
|||
# carry the devnet's activation height here, the same N as every other devnet node, before it is cut (Mac and CI alike:
|
||||
# make-payload.sh sources this file). Rule and order: docs/plans/difficulty-v2-rollout-devnet.md.
|
||||
# Example: NODE_OVERRIDE_PARAMS='{"difficulty_v2_activation_daa": 123456}'
|
||||
NODE_OVERRIDE_PARAMS=''
|
||||
NODE_OVERRIDE_PARAMS='{"difficulty_v2_activation_daa":33000,"proving_v0_activation_daa":84100,"fees_v1_activation_daa":210000,"finality_v3_activation_daa":135200}'
|
||||
|
||||
# igneum_secret_file <env var name> <base name> -> the file to read: the variable when set, else <base>.next when it
|
||||
# exists, else <base>; IGNEUM_CONFIG_DIR (tests) replaces ~/.config/igneum
|
||||
|
|
@ -109,7 +109,7 @@ if [ "${BASH_SOURCE[0]}" = "$0" ]; then
|
|||
check "fingerprint is 8 hex" "$(igneum_fingerprint abc | grep -cE '^[0-9a-f]{8}$')" "1"
|
||||
check "fingerprint of abc" "$(igneum_fingerprint abc)" "ba7816bf"
|
||||
# 5. the written JSON: defaults pick the .next pair; the values land; nothing printed carries them
|
||||
out="$(write_packaged_config "$T/a.json")"
|
||||
out="$(NODE_OVERRIDE_PARAMS='' write_packaged_config "$T/a.json")"
|
||||
check "output names the .next key file" "$(printf '%s' "$out" | grep -c 'log-intake-key.next')" "1"
|
||||
check "output never carries the key" "$(printf '%s' "$out" | grep -c 'nextkeyvalue')" "0"
|
||||
check "output never carries the token" "$(printf '%s' "$out" | grep -c 'tok2new')" "0"
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ if [ ! -x "$SIGNER" ]; then
|
|||
echo "building igneum-ota-sign"
|
||||
(cd "$ROOT/app/igneum-app" && nice -n 19 cargo build --release -j 4 --bin igneum-ota-sign --quiet)
|
||||
fi
|
||||
EMBEDDED="$("$SIGNER" embedded | head -1)"
|
||||
EMBEDDED="$("$SIGNER" embedded | sed -n 1p)"
|
||||
OURS="$(tr -d '[:space:]' < "$PUB")"
|
||||
if [ "$EMBEDDED" != "$OURS" ]; then
|
||||
echo "the public key in app/igneum-app/src/manifest.rs ($EMBEDDED) is not $PUB ($OURS); the apps would refuse this file" >&2
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ if [ ! -x "$SIGNER" ]; then
|
|||
echo "building igneum-ota-sign"
|
||||
(cd "$ROOT/app/igneum-app" && nice -n 19 cargo build --release -j 4 --bin igneum-ota-sign --quiet)
|
||||
fi
|
||||
EMBEDDED="$("$SIGNER" embedded | head -1)"
|
||||
EMBEDDED="$("$SIGNER" embedded | sed -n 1p)"
|
||||
OURS="$(tr -d '[:space:]' < "$PUB")"
|
||||
if [ "$EMBEDDED" != "$OURS" ]; then
|
||||
echo "the public key in app/igneum-app/src/manifest.rs ($EMBEDDED) is not $PUB ($OURS); the apps would refuse this manifest" >&2
|
||||
|
|
|
|||
|
|
@ -97,7 +97,7 @@ grep -q "^inner-identical True$" "$T/inner2.txt" && grep -q "^sig-identical True
|
|||
expect_ok "list reads the folder" "$PUBLISH" list --dest "$D"
|
||||
|
||||
echo "== the key"
|
||||
EMB="$("$SIGNER" embedded | head -1)"
|
||||
EMB="$("$SIGNER" embedded | sed -n 1p)"
|
||||
[ "$EMB" = "$(tr -d '[:space:]' < "$PUB")" ] && ok "the embedded key is the Mac's OTA public key" || bad "the embedded key is not $PUB"
|
||||
|
||||
echo
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
#define ArtDir "..\..\brand\icons"
|
||||
#endif
|
||||
#ifndef AppVersion
|
||||
#define AppVersion "0.3.9"
|
||||
#define AppVersion "0.3.10"
|
||||
#endif
|
||||
#define AppName "Igneum Miner"
|
||||
#define Publisher "Igneum"
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
a24ab01a2e10cecf575bcea372310b871081ec64
|
||||
21d4c73c6ce32fcbd68391968e85827339a511c0
|
||||
|
|
|
|||
|
|
@ -6,7 +6,13 @@
|
|||
# signed hash is trusted, never the host. Nothing secret goes in: the jobs file is public.
|
||||
#
|
||||
# packaging/windows/push-build-inputs.sh [--node <fork worktree, default vendor/igneum-node-v4>]
|
||||
# [--node-tests "igneum-miner"] [--app-tests "igneum-app"] [--no-app] [--no-deploy] [--out <zip>]
|
||||
# [--node-tests "igneum-miner"] [--app-tests "igneum-app"] [--no-app] [--no-node] [--no-deploy] [--out <zip>]
|
||||
# [--name <basename.zip>] the zip's name in the downloads folder (default build-inputs.zip; build-job.mjs gives
|
||||
# every job its own name since 5 October 2026 night: with one shared name a job
|
||||
# published while another agent's pack landed pinned THAT agent's sources, three
|
||||
# times in one evening; zips older than two days are pruned here)
|
||||
# --no-node packs and builds the app engine only (a UI or engine change with no node change: miner-ui-2, 5 October
|
||||
# 2026); the manifest's node block says "none" and the builds list has no node entry.
|
||||
#
|
||||
# What goes in (under igneum-build-inputs/):
|
||||
# manifest.json created_at, node {branch, commit, dirty, source}, repo {branch, commit, dirty}, app_version,
|
||||
|
|
@ -32,19 +38,23 @@ APP_TESTS="${IGNEUM_BUILD_APP_TESTS:-igneum-app}"
|
|||
WITH_APP=1
|
||||
DEPLOY=1
|
||||
OUT=""
|
||||
NAME=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--node) NODE_SRC="$2"; shift 2 ;;
|
||||
--node-tests) NODE_TESTS="$2"; shift 2 ;;
|
||||
--app-tests) APP_TESTS="$2"; shift 2 ;;
|
||||
--no-app) WITH_APP=0; shift ;;
|
||||
--no-node) WITH_NODE=0; shift ;;
|
||||
--no-deploy) DEPLOY=0; shift ;;
|
||||
--out) OUT="$2"; shift 2 ;;
|
||||
--name) NAME="$2"; shift 2 ;;
|
||||
*) echo "unknown argument: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
case "$NODE_SRC" in /*) ;; *) NODE_SRC="$ROOT/$NODE_SRC" ;; esac
|
||||
[ -f "$NODE_SRC/Cargo.toml" ] || { echo "no node source at $NODE_SRC (a vendor/igneum-node-* worktree)" >&2; exit 1; }
|
||||
[ "${WITH_NODE:-1}" = 0 ] || [ -f "$NODE_SRC/Cargo.toml" ] || { echo "no node source at $NODE_SRC (a vendor/igneum-node-* worktree)" >&2; exit 1; }
|
||||
[ "${WITH_NODE:-1}" = 1 ] || [ "$WITH_APP" = 1 ] || { echo "--no-node with --no-app packs nothing" >&2; exit 2; }
|
||||
[ -f "$ROOT/app/igneum-app/Cargo.toml" ] || { echo "no app crate at $ROOT/app/igneum-app" >&2; exit 1; }
|
||||
[ -f "$ROOT/brand/icons/igneum.ico" ] || { echo "no $ROOT/brand/icons/igneum.ico (python3 brand/icons/make-icons.py)" >&2; exit 1; }
|
||||
command -v rsync >/dev/null || { echo "rsync is needed" >&2; exit 1; }
|
||||
|
|
@ -58,7 +68,9 @@ if [ -z "$OUT" ]; then
|
|||
TOKEN="$(tr -d '[:space:]' < "$TOKEN_FILE")"
|
||||
[ -n "$DLSITE" ] && [ -d "$DLSITE/dl/$TOKEN" ] || { echo "no downloads folder: set IGNEUM_DLSITE or ~/.config/igneum/dlsite-dir (it must hold dl/<token>/)" >&2; exit 1; }
|
||||
DEST="$DLSITE/dl/$TOKEN"
|
||||
OUT="$DEST/build-inputs.zip"
|
||||
OUT="$DEST/${NAME:-build-inputs.zip}"
|
||||
# per-job zips older than two days (a job expires in two days) go, with their sha256 and manifest
|
||||
find "$DEST" -maxdepth 1 -name 'build-inputs-*' \( -name '*.zip' -o -name '*.sha256' -o -name '*.json' \) -mtime +2 -delete 2>/dev/null || true
|
||||
else
|
||||
TOKEN=""
|
||||
DEST="$(cd "$(dirname "$OUT")" && pwd)"
|
||||
|
|
@ -77,8 +89,13 @@ APP_VERSION="$(sed -n 's/^version = "\(.*\)"/\1/p' "$ROOT/app/igneum-app/Cargo.t
|
|||
TMP="$(mktemp -d)"
|
||||
STAGE="$TMP/igneum-build-inputs"
|
||||
mkdir -p "$STAGE/node" "$STAGE/app" "$STAGE/brand"
|
||||
echo "packing the node fork $NODE_SRC ($NODE_BRANCH $NODE_COMMIT$([ "$NODE_DIRTY" = true ] && echo ', dirty'))"
|
||||
rsync -a --exclude 'target' --exclude 'target-*' --exclude 'target*' --exclude '.git' --exclude '.DS_Store' --exclude '*.vhdx' "$NODE_SRC/" "$STAGE/node/"
|
||||
if [ "${WITH_NODE:-1}" = 1 ]; then
|
||||
echo "packing the node fork $NODE_SRC ($NODE_BRANCH $NODE_COMMIT$([ "$NODE_DIRTY" = true ] && echo ', dirty'))"
|
||||
rsync -a --exclude 'target' --exclude 'target-*' --exclude 'target*' --exclude '.git' --exclude '.DS_Store' --exclude '*.vhdx' "$NODE_SRC/" "$STAGE/node/"
|
||||
else
|
||||
echo "no node (--no-node): the app engine only"
|
||||
NODE_BRANCH=none; NODE_COMMIT=none; NODE_DIRTY=false
|
||||
fi
|
||||
if [ "$WITH_APP" = 1 ]; then
|
||||
echo "packing app/igneum-app ($APP_VERSION) and brand/icons"
|
||||
rsync -a --exclude 'target' --exclude '.DS_Store' "$ROOT/app/igneum-app/" "$STAGE/app/igneum-app/"
|
||||
|
|
@ -91,12 +108,12 @@ rsync -a --exclude 'target' --exclude 'target-*' --exclude '.DS_Store' "$ROOT/ig
|
|||
echo "packing proto-cuda (without nvrtc/redist)"
|
||||
rsync -a --exclude 'nvrtc/redist' --exclude '.DS_Store' --exclude '*.exe' --exclude '*.dll' "$ROOT/proto-cuda/" "$STAGE/proto-cuda/"
|
||||
|
||||
python3 - "$STAGE/manifest.json" "$NODE_BRANCH" "$NODE_COMMIT" "$NODE_DIRTY" "${NODE_SRC#"$ROOT"/}" "$REPO_BRANCH" "$REPO_COMMIT" "$REPO_DIRTY" "$APP_VERSION" "$WITH_APP" "$NODE_TESTS" "$APP_TESTS" <<'PY'
|
||||
python3 - "$STAGE/manifest.json" "$NODE_BRANCH" "$NODE_COMMIT" "$NODE_DIRTY" "${NODE_SRC#"$ROOT"/}" "$REPO_BRANCH" "$REPO_COMMIT" "$REPO_DIRTY" "$APP_VERSION" "$WITH_APP" "$NODE_TESTS" "$APP_TESTS" "${WITH_NODE:-1}" <<'PY'
|
||||
import json, sys, datetime
|
||||
out, nb, nc, nd, ns, rb, rc, rd, av, with_app, node_tests, app_tests = sys.argv[1:13]
|
||||
builds = [{"dir": "node", "packages": ["kaspad", "igneum-miner"], "features": ["kaspad/igneum-pow"], "bins": ["igneumd", "igneum-miner"], "targets": ["linux", "windows"]}]
|
||||
out, nb, nc, nd, ns, rb, rc, rd, av, with_app, node_tests, app_tests, with_node = sys.argv[1:14]
|
||||
builds = [{"dir": "node", "packages": ["kaspad", "igneum-miner"], "features": ["kaspad/igneum-pow"], "bins": ["igneumd", "igneum-miner"], "targets": ["linux", "windows"]}] if with_node == "1" else []
|
||||
tests = []
|
||||
if node_tests.strip():
|
||||
if node_tests.strip() and with_node == "1":
|
||||
tests.append({"dir": "node", "packages": node_tests.split()})
|
||||
if with_app == "1":
|
||||
builds.append({"dir": "app/igneum-app", "packages": ["igneum-app"], "bins": ["igneum-app"], "targets": ["linux", "windows"], "optional_on": ["linux"]})
|
||||
|
|
@ -135,14 +152,14 @@ if [ "$DEPLOY" = 1 ]; then
|
|||
LIVE="$(mktemp)"
|
||||
ok=0
|
||||
for try in 1 2 3 4 5 6; do
|
||||
code="$(curl -s -o "$LIVE" -w '%{http_code}' "https://dl.igneum.network/dl/$TOKEN/build-inputs.sha256")"
|
||||
echo "https://dl.igneum.network/dl/<token>/build-inputs.sha256 -> HTTP $code (try $try)"
|
||||
code="$(curl -s -o "$LIVE" -w '%{http_code}' "https://dl.igneum.network/dl/$TOKEN/$(basename "${OUT%.zip}").sha256")"
|
||||
echo "https://dl.igneum.network/dl/<token>/$(basename "${OUT%.zip}").sha256 -> HTTP $code (try $try)"
|
||||
if [ "$code" = 200 ] && [ "$(tr -d '[:space:]' < "$LIVE")" = "$SUM" ]; then ok=1; break; fi
|
||||
sleep 10
|
||||
done
|
||||
[ "$ok" = 1 ] || { echo "the live sha256 is not reachable or is not this zip's after 6 tries; check the deploy output" >&2; rm -f "$LIVE"; exit 1; }
|
||||
rm -f "$LIVE"
|
||||
echo "live: build-inputs.zip verified by sha256"
|
||||
echo "live: $(basename "$OUT") verified by sha256"
|
||||
elif [ -n "$TOKEN" ]; then
|
||||
echo "not deployed (--no-deploy): cd $DLSITE && npx --yes vercel@latest --global-config ~/.config/igneum/vercel deploy --prod --yes"
|
||||
else
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ if [ ! -x "$SIGNER" ]; then
|
|||
echo "building igneum-ota-sign"
|
||||
(cd "$ROOT/app/igneum-app" && nice -n 19 cargo build --release -j 4 --bin igneum-ota-sign --quiet)
|
||||
fi
|
||||
EMBEDDED="$("$SIGNER" embedded | head -1)"
|
||||
EMBEDDED="$("$SIGNER" embedded | sed -n 1p)"
|
||||
[ "$EMBEDDED" = "$(tr -d '[:space:]' < "$PUB")" ] || { echo "the public key in app/igneum-app/src/manifest.rs ($EMBEDDED) is not $PUB; the runner would refuse this signature" >&2; exit 1; }
|
||||
|
||||
# the manifest: what is in the zip, from where, when. IGNEUM_NODE_SRC names the worktree the exes were built from
|
||||
|
|
|
|||
|
|
@ -58,6 +58,16 @@
|
|||
// Vendor device attributes (cl_amd_device_attribute_query, cl_nv_device_attribute_query).
|
||||
#define IG_CL_DEVICE_WAVEFRONT_WIDTH_AMD 0x4043
|
||||
#define IG_CL_DEVICE_WARP_SIZE_NV 0x4003
|
||||
// Where the card sits on the PCI bus, so the app can tell one physical card listed by two OpenCL platforms (two
|
||||
// AMD ICDs after a driver upgrade, PC 1 on 5 October 2026) from two cards: CL_DEVICE_TOPOLOGY_AMD and the NVIDIA pair.
|
||||
#define IG_CL_DEVICE_TOPOLOGY_AMD 0x4037
|
||||
#define IG_CL_DEVICE_TOPOLOGY_TYPE_PCIE_AMD 1
|
||||
#define IG_CL_DEVICE_PCI_BUS_ID_NV 0x4008
|
||||
#define IG_CL_DEVICE_PCI_SLOT_ID_NV 0x4009
|
||||
typedef union {
|
||||
struct { cl_uint type; cl_uint data[5]; } raw;
|
||||
struct { cl_uint type; cl_char unused[17]; cl_char bus; cl_char device; cl_char function; } pcie;
|
||||
} ig_topology_amd;
|
||||
|
||||
typedef cl_int (CL_API_CALL *ig_pfn_subgroup_info)(cl_kernel, cl_device_id, cl_uint, size_t, const void*, size_t, void*, size_t*);
|
||||
|
||||
|
|
@ -325,6 +335,7 @@ typedef struct {
|
|||
int dMajor, dMinor; // device (platform profile) version
|
||||
cl_uint amdWavefront, nvWarp; // 0 if not reported
|
||||
int dupOf; // index of the same card on a newer platform of the same vendor, -1 if none (5 October 2026)
|
||||
char pci[32]; // "01:00.0" (bus:device.function) when the vendor extension reports it, else ""
|
||||
} DeviceInfo;
|
||||
|
||||
/* The driver version as a number for ordering ("3683.0 (PAL,LC)" -> 3683.0; "617.14" -> 617.14; 0 when unreadable). */
|
||||
|
|
@ -417,10 +428,19 @@ static int enumerateDevices(DeviceInfo** outList) {
|
|||
clGetDeviceInfo(devs[d], CL_DEVICE_MAX_WORK_GROUP_SIZE, sizeof(di.maxWorkGroup), &di.maxWorkGroup, NULL);
|
||||
if (sscanf(di.cVersion, "OpenCL C %d.%d", &di.cMajor, &di.cMinor) != 2) { di.cMajor = 1; di.cMinor = 2; }
|
||||
if (sscanf(di.version, "OpenCL %d.%d", &di.dMajor, &di.dMinor) != 2) { di.dMajor = 1; di.dMinor = 2; }
|
||||
if (strstr(di.extensions, "cl_amd_device_attribute_query"))
|
||||
if (strstr(di.extensions, "cl_amd_device_attribute_query")) {
|
||||
ig_topology_amd topo;
|
||||
memset(&topo, 0, sizeof(topo));
|
||||
clGetDeviceInfo(devs[d], IG_CL_DEVICE_WAVEFRONT_WIDTH_AMD, sizeof(di.amdWavefront), &di.amdWavefront, NULL);
|
||||
if (strstr(di.extensions, "cl_nv_device_attribute_query"))
|
||||
if (clGetDeviceInfo(devs[d], IG_CL_DEVICE_TOPOLOGY_AMD, sizeof(topo), &topo, NULL) == CL_SUCCESS && topo.raw.type == IG_CL_DEVICE_TOPOLOGY_TYPE_PCIE_AMD)
|
||||
snprintf(di.pci, sizeof(di.pci), "%02x:%02x.%x", (unsigned)(unsigned char)topo.pcie.bus, (unsigned)(unsigned char)topo.pcie.device, (unsigned)(unsigned char)topo.pcie.function);
|
||||
}
|
||||
if (strstr(di.extensions, "cl_nv_device_attribute_query")) {
|
||||
cl_uint bus = 0, slot = 0;
|
||||
clGetDeviceInfo(devs[d], IG_CL_DEVICE_WARP_SIZE_NV, sizeof(di.nvWarp), &di.nvWarp, NULL);
|
||||
if (clGetDeviceInfo(devs[d], IG_CL_DEVICE_PCI_BUS_ID_NV, sizeof(bus), &bus, NULL) == CL_SUCCESS && clGetDeviceInfo(devs[d], IG_CL_DEVICE_PCI_SLOT_ID_NV, sizeof(slot), &slot, NULL) == CL_SUCCESS)
|
||||
snprintf(di.pci, sizeof(di.pci), "%02x:%02x.0", (unsigned)(bus & 0xff), (unsigned)(slot & 0xff));
|
||||
}
|
||||
list = (DeviceInfo*)realloc(list, sizeof(DeviceInfo) * (size_t)(n + 1));
|
||||
list[n++] = di;
|
||||
}
|
||||
|
|
@ -442,7 +462,10 @@ static void printDevice(int idx, const DeviceInfo* d, int chosen) {
|
|||
return;
|
||||
}
|
||||
printf("%s[%d] %s | %s (%s)\n", chosen ? "*" : " ", idx, d->name, d->platformName, d->platformVersion);
|
||||
printf(" %s, vendor %s, driver %s, %s, %u compute units, %u MHz\n", typeName(d->type), d->vendor, d->driver, d->cVersion, d->computeUnits, d->clockMHz);
|
||||
// the app's detect.rs reads this line: type, vendor, driver, the compute units, and the PCI address when known
|
||||
printf(" %s, vendor %s, driver %s, %s, %u compute units, %u MHz", typeName(d->type), d->vendor, d->driver, d->cVersion, d->computeUnits, d->clockMHz);
|
||||
if (d->pci[0]) printf(", pci %s", d->pci);
|
||||
printf("\n");
|
||||
printf(" global %llu MiB, max alloc %llu MiB, local %llu KiB, max work-group %llu, sub-group extension: %s",
|
||||
(unsigned long long)(d->globalMem >> 20), (unsigned long long)(d->maxAlloc >> 20), (unsigned long long)(d->localMem >> 10),
|
||||
(unsigned long long)d->maxWorkGroup, subExt);
|
||||
|
|
|
|||
|
|
@ -136,6 +136,7 @@ async function machines(sql) {
|
|||
accepted_lifetime: g.app && g.app.status ? g.app.status.accepted_total ?? null : null,
|
||||
faults: cards.reduce((a, c) => a + (c.faults || 0), 0),
|
||||
telemetry: g.app ? g.app.telemetry : [], events: g.app ? g.app.events : [],
|
||||
gpus: g.app && g.app.gpus ? g.app.gpus : [], gpus_at: g.app && g.app.gpus_at ? g.app.gpus_at : null, hotplug: g.app && g.app.hotplug ? g.app.hotplug : [],
|
||||
labels: g.labels.sort((a, b) => (b.at > a.at ? 1 : -1)), runs: [...g.runs],
|
||||
};
|
||||
});
|
||||
|
|
@ -145,7 +146,7 @@ async function machines(sql) {
|
|||
// several Mac installs (test runs): the newest is "Mac", the others keep their id
|
||||
let macSeen = false;
|
||||
for (const m of out) if (m.platform === 'mac' && m.name === 'Mac') { if (macSeen) m.name = `Mac ${m.id8}`; macSeen = true; }
|
||||
for (const n of ['Mac', 'PC 1', 'PC 2', "Sam's Mac"]) if (!out.some(m => m.name === n)) out.push({ id: n, id8: Object.keys(NAMES).find(k => NAMES[k] === n) || null, platform: n.endsWith('Mac') ? 'mac' : 'win', name: n, host: null, legacy: false, last_seen: null, silent_s: null, silent: true, app_version: null, app: null, node: {}, cards: [], hash_total: 0, accepted_total: 0, faults: 0, telemetry: [], events: [], labels: [], runs: [], never: true });
|
||||
for (const n of ['Mac', 'PC 1', 'PC 2', "Sam's Mac"]) if (!out.some(m => m.name === n)) out.push({ id: n, id8: Object.keys(NAMES).find(k => NAMES[k] === n) || null, platform: n.endsWith('Mac') ? 'mac' : 'win', name: n, host: null, legacy: false, last_seen: null, silent_s: null, silent: true, app_version: null, app: null, node: {}, cards: [], hash_total: 0, accepted_total: 0, faults: 0, telemetry: [], events: [], gpus: [], gpus_at: null, hotplug: [], labels: [], runs: [], never: true });
|
||||
out.sort((a, b) => order(a) - order(b) || (b.last_seen || '').localeCompare(a.last_seen || ''));
|
||||
return { machines: out.filter(m => NAMED.has(m.name) || (m.silent_s !== null && m.silent_s < HIDE_AFTER_S)), silent_after_s: SILENT_S, hide_after_s: HIDE_AFTER_S };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -76,6 +76,12 @@ export function parseHeader(text) {
|
|||
const m = HEADER.exec(text || '');
|
||||
return m ? { version: m[1], id8: m[2], platform: m[3], node: m[4].replace(/^igneumd[_\/]/, '') } : null;
|
||||
}
|
||||
/// "NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]"
|
||||
export function parseCardsLine(text) {
|
||||
if (!text || text.trim() === 'none') return [];
|
||||
return text.split(' | ').map(p => { const m = /^(.*) \[([^,\]]+), ([^\]]+)\]$/.exec(p.trim()); return m ? { name: m[1], kind: m[2], state: m[3] } : { name: p.trim(), kind: 'unknown', state: '' }; });
|
||||
}
|
||||
|
||||
export function parseAppTail(tail) {
|
||||
const lines = tail.split('\n');
|
||||
const a = { telemetry: [], events: [], jobs: [] };
|
||||
|
|
@ -119,6 +125,11 @@ export function parseAppTail(tail) {
|
|||
if (e && e[2] !== 'block') a.events.push({ at: new Date(Number(e[1]) * 1000).toISOString(), kind: e[2], text: e[3].slice(0, 200) });
|
||||
}
|
||||
a.events = a.events.slice(-6);
|
||||
// the card list (app/igneum-app/src/hotplug.rs cards_line): the newest "cards: <name> [<kind>, <state>] | ..." line,
|
||||
// written at start, on every hot-plug change and every 10 minutes
|
||||
const cl = lastMatch(lines, /^(\d+)(?:\.\d+)? cards: (.+)$/);
|
||||
if (cl) { a.gpus = parseCardsLine(cl[2]); a.gpus_at = new Date(Number(cl[1]) * 1000).toISOString(); }
|
||||
a.hotplug = lines.filter(l => /^\d+(?:\.\d+)? \[(ok|warn|info)\] (New card: |Card removed: |.+: not usable \()/.test(l)).slice(-4).map(l => ({ at: stampOf(l), text: strip(l).replace(/^\[\w+\] /, '').slice(0, 200) }));
|
||||
// A clean stop: the app logs "quit: ..." (Quit, Stop, or an update: "[info] installing Igneum Miner ...") and uploads
|
||||
// once more before it exits; when no status line follows, the machine was stopped on purpose, not lost (4 October
|
||||
// 2026: a Mac stopped at 14:47 UTC read "silent 4h" for the whole afternoon, the same as a crash or a lost network).
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// node --test relay/test/parse.test.mjs relay/test/auth.test.mjs (no dependencies; CI runs both in the site job)
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseLabel, parseMinerTail, markStale, STALE_S, PACK_MISMATCH } from '../lib/parse.mjs';
|
||||
import { parseLabel, parseMinerTail, parseAppTail, parseCardsLine, markStale, STALE_S, PACK_MISMATCH } from '../lib/parse.mjs';
|
||||
|
||||
test('labels: every vendor the app names, the app log, the node log, the legacy launchers', () => {
|
||||
assert.deepEqual(parseLabel('miner-nvidia-ae432dc7-1'), { id: 'ae432dc7', platform: 'win', stream: 'miner', vendor: 'nvidia', card: 1 });
|
||||
|
|
@ -85,6 +85,26 @@ test('app tail: the newest update line decides the OTA state: staged beats an ol
|
|||
assert.deepEqual([inst.ota.state, inst.ota.version], ['installing', '0.3.4']);
|
||||
});
|
||||
|
||||
test('app tail: the cards line gives kind and state per card, and the hot-plug events', () => {
|
||||
assert.deepEqual(parseCardsLine('NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | AMD Radeon RX 9070 XT [discrete, not usable (Code 43)]'),
|
||||
[{ name: 'NVIDIA GeForce RTX 5090', kind: 'discrete', state: 'mining' }, { name: 'gfx1036', kind: 'integrated', state: 'off' }, { name: 'AMD Radeon RX 9070 XT', kind: 'discrete', state: 'not usable (Code 43)' }]);
|
||||
assert.deepEqual(parseCardsLine('none'), []);
|
||||
const tail = [
|
||||
'1791140500 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off]',
|
||||
'1791140560 [ok] New card: gfx1201, mining',
|
||||
'1791140561 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, waiting]',
|
||||
'1791140800 [warn] Card removed: gfx1201; its worker stopped',
|
||||
'1791140801 cards: NVIDIA GeForce RTX 5090 [discrete, mining] | gfx1036 [integrated, off] | gfx1201 [discrete, removed]',
|
||||
'1791140830 status: accepted 5 blocks (1 this run, dev fee 0), 120.00 MH/s, mining | node 100 blocks, 3 peers, synced | up 1h',
|
||||
].join('\n');
|
||||
const a = parseAppTail(tail);
|
||||
assert.equal(a.gpus.length, 3);
|
||||
assert.equal(a.gpus[2].state, 'removed');
|
||||
assert.equal(a.gpus_at, new Date(1791140801000).toISOString());
|
||||
assert.deepEqual(a.hotplug.map(h => h.text), ['New card: gfx1201, mining', 'Card removed: gfx1201; its worker stopped']);
|
||||
assert.equal(parseAppTail('1791140830 status: x | node y | up 1h').gpus, undefined);
|
||||
});
|
||||
|
||||
test('pack mismatch: the refused-pack tail of 5 October 2026 reads "pack mismatch, rebuilding", a healthy tail does not', () => {
|
||||
// PC 2, nvidia-1ccfe586-1, 18:27 to 18:28 UTC: the worker refused packs\devnet at every start and the miner restarted it
|
||||
const refused = [
|
||||
|
|
|
|||
|
|
@ -287,7 +287,7 @@ input[type=text]{width:100%}
|
|||
['checkpoint', n.checkpoint != null ? nf(n.checkpoint) : '?'],
|
||||
['run', `<small>${esc((n.run_id || (mc.runs && mc.runs[0]) || '').replace(/^.*-(\d{8}-\d{6})$/, '$1'))}</small>`],
|
||||
])}
|
||||
${cardTable(mc.cards)}${tele}
|
||||
${cardTable(mc.cards)}${mc.gpus && mc.gpus.length ? `<div class="note" style="margin-top:8px">GPUs ${mc.gpus_at ? ago(mc.gpus_at) + ' ago' : ''}: ${mc.gpus.map(g => `${esc(g.name)} <span style="color:var(--ash)">[${esc(g.kind)}, ${/not usable|removed/.test(g.state) ? `<span style="color:var(--red)">${esc(g.state)}</span>` : esc(g.state)}]</span>`).join(' | ')}</div>` : ''}${(mc.hotplug || []).map(h => `<div class="note">hot-plug ${ago(h.at)} ago: ${esc(h.text)}</div>`).join('')}${tele}
|
||||
${mc.app && (mc.app.ota || mc.app.power_cap || mc.app.jobs_file || (mc.app.jobs && mc.app.jobs.length) || mc.app.status) ? `<div style="margin-top:8px">
|
||||
${mc.app.status ? `<div class="ev"><span class="t">${when(mc.app.status.at)}</span><span class="k">status</span><span>${esc(mc.app.status.miner)} | node ${esc(mc.app.status.node)} | up ${esc(mc.app.status.up)}</span></div>` : ''}
|
||||
${mc.app.ota ? `<div class="ev"><span class="t">${when(mc.app.ota.at)}</span><span class="k ${(mc.app.ota.state === 'updated' || mc.app.ota.state === 'current') ? 'ok' : ''}">ota</span><span>${esc(mc.app.ota.text)}</span></div>` : ''}
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ p{margin:0;color:var(--ink-2);max-width:52ch}
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
297
site/address.html
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Igneum address</title>
|
||||
<meta name="description" content="One Igneum address: the blocks it mined in the last day, what they earned, and its balance.">
|
||||
<link rel="canonical" href="https://igneum.network/address">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="theme-color" content="#0C0C0E">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Igneum">
|
||||
<meta property="og:title" content="Igneum address">
|
||||
<meta property="og:description" content="One Igneum address: the blocks it mined in the last day, what they earned, and its balance.">
|
||||
<meta property="og:url" content="https://igneum.network/address">
|
||||
<meta property="og:image" content="https://igneum.network/og-small.png?v=3">
|
||||
<meta property="og:image:width" content="256">
|
||||
<meta property="og:image:height" content="256">
|
||||
<meta property="og:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Igneum address">
|
||||
<meta name="twitter:description" content="One Igneum address: the blocks it mined in the last day, what they earned, and its balance.">
|
||||
<meta name="twitter:image" content="https://igneum.network/og-small.png?v=3">
|
||||
<meta name="twitter:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48">
|
||||
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<!-- head:start -->
|
||||
<link rel="preload" href="/fonts/unbounded-900.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/fonts/plex-sans-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<style>
|
||||
/* Shared by every page. Source: site/partials/head.html, injected by site/build.mjs between the head markers. Edit the partial, not the page. */
|
||||
/* Fonts, self-hosted (latin subsets, OFL): Unbounded 500/700/900, IBM Plex Sans 400/500/600, IBM Plex Mono 400/500. Fallbacks carry size and ascent overrides so the swap does not move the layout. */
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/unbounded-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/unbounded-700.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(/fonts/unbounded-900.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-sans-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-sans-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(/fonts/plex-sans-600.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-mono-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-mono-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded Fallback';src:local('Arial Black'),local('Arial-Black'),local('Impact');size-adjust:114%;ascent-override:87.5%;descent-override:21.5%;line-gap-override:0%}
|
||||
@font-face{font-family:'Plex Sans Fallback';src:local('Arial'),local('Helvetica Neue'),local('Helvetica');size-adjust:100.5%;ascent-override:102%;descent-override:27.4%;line-gap-override:0%}
|
||||
@font-face{font-family:'Plex Mono Fallback';src:local('Courier New'),local('Menlo');size-adjust:100%;ascent-override:102.5%;descent-override:27.5%;line-gap-override:0%}
|
||||
:root{--f-sans:'IBM Plex Sans','Plex Sans Fallback',system-ui,-apple-system,sans-serif;--f-display:'Unbounded','Unbounded Fallback',sans-serif;--f-mono:'IBM Plex Mono','Plex Mono Fallback',ui-monospace,Menlo,monospace;
|
||||
/* site chrome tokens (nav, footer, skip link). Dark by default; the litepaper sets light values and resets these in dark mode */
|
||||
--nav-h:68px;--ui-bg:rgba(12,12,14,.84);--ui-menu:#0C0C0E;--ui-ink:#F4F1EC;--ui-ink-2:#C9C7C2;--ui-ash:#9A9A9E;--ui-line:#2A2A30;--ui-line-2:#3A3A42;--ui-accent:#F2541B;--ui-accent-ink:#0C0C0E;--ui-hot:#FFB35C;--ui-hover:#FF6A2B;--ui-tint:rgba(242,84,27,.12)}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
h1,h2,h3{text-wrap:balance}
|
||||
p,li,dd,figcaption{text-wrap:pretty}
|
||||
a:focus-visible,button:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--ui-accent);outline-offset:3px;border-radius:6px}
|
||||
.skip{position:absolute;left:12px;top:-80px;z-index:50;background:var(--ui-accent);color:var(--ui-accent-ink);padding:10px 14px;border-radius:10px;font:600 15px/1.2 var(--f-sans);text-decoration:none}
|
||||
.skip:focus{top:12px}
|
||||
/* nav: one bar on every page, the mark in its black square, seven links and the miner button, a menu under 941 px (the eight items need 688 px beside the brand at 15 px, so the bar fits from 941 px up without a wrapped label) */
|
||||
.nav{position:sticky;top:0;z-index:20;background:var(--ui-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--ui-line);color:var(--ui-ink)}
|
||||
.nav .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:var(--nav-h)}
|
||||
.nav a{color:inherit;text-decoration:none}
|
||||
.brand{display:inline-flex;align-items:center;gap:10px;color:var(--ui-ink);text-decoration:none;flex:0 0 auto}
|
||||
.brand .mark{display:block;flex:0 0 auto}
|
||||
.brand .word{font-family:var(--f-display);font-weight:900;font-size:20px;letter-spacing:.06em;line-height:1}
|
||||
.nav .links{display:flex;align-items:center;gap:clamp(14px,2vw,26px);font-size:15px;font-weight:500}
|
||||
.nav .links a{color:var(--ui-ink-2);padding:4px 0;border-bottom:2px solid transparent;white-space:nowrap;transition:color .15s ease,border-color .15s ease}
|
||||
.nav .links a:hover,.nav .brand:hover{color:var(--ui-hot);text-decoration:none}
|
||||
.nav .brand:hover{color:var(--ui-ink)}
|
||||
.nav .links a[aria-current="page"]{color:var(--ui-ink);border-bottom-color:var(--ui-accent)}
|
||||
.nav .links a.cta{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:8px 16px;border-radius:10px;font-weight:600;background:var(--ui-accent);color:var(--ui-accent-ink);border:1px solid var(--ui-accent);transition:background .15s ease,transform .15s ease}
|
||||
.nav .links a.cta:hover{background:var(--ui-hover);border-color:var(--ui-hover);color:var(--ui-accent-ink);transform:translateY(-1px)}
|
||||
.burger{display:none;background:none;border:1px solid var(--ui-line-2);color:var(--ui-ink);border-radius:10px;width:44px;height:44px;align-items:center;justify-content:center;cursor:pointer;padding:0;flex:0 0 auto}
|
||||
.burger .x{display:none}.burger[aria-expanded="true"] .x{display:block}.burger[aria-expanded="true"] .bars{display:none}
|
||||
@media (max-width:940px){
|
||||
.nav .links{display:none;position:absolute;left:0;right:0;top:100%;background:var(--ui-menu);border-bottom:1px solid var(--ui-line);box-shadow:0 24px 40px rgba(0,0,0,.35);padding:10px var(--gutter,16px) 18px;flex-direction:column;align-items:stretch;gap:2px}
|
||||
.nav .links a{padding:13px 10px;border-radius:10px;border-bottom:0;font-size:16px}
|
||||
.nav .links a[aria-current="page"]{background:var(--ui-tint)}
|
||||
.nav .links a.cta{margin-top:8px;min-height:48px}
|
||||
.nav .links.open{display:flex}
|
||||
.burger{display:inline-flex}
|
||||
}
|
||||
/* footer, the same on every page */
|
||||
.foot{border-top:1px solid var(--ui-line);color:var(--ui-ink);margin-top:var(--sec,64px)}
|
||||
.foot .wrap{padding-block:48px 32px}
|
||||
.foot-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,200px),1fr));gap:32px}
|
||||
.foot-brand{display:flex;flex-direction:column;gap:12px;max-width:34ch}
|
||||
.foot-brand .brand{align-self:flex-start}.foot-brand .word{font-size:16px}
|
||||
.foot-brand p{color:var(--ui-ash);font-size:14px;margin:0}
|
||||
.foot-col{display:flex;flex-direction:column;gap:10px;font-size:15px}
|
||||
.foot-col .eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ui-ash);margin-bottom:4px}
|
||||
.foot-col a{color:var(--ui-ink-2);text-decoration:none;display:inline-flex;align-items:center;gap:6px;align-self:flex-start}
|
||||
.foot-col a:hover{color:var(--ui-hot);text-decoration:none}
|
||||
.foot-base{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px 24px;margin-top:36px;padding-top:20px;border-top:1px solid var(--ui-line);font-size:13px;color:var(--ui-ash)}
|
||||
.foot-base span{text-wrap:balance}.foot-base .mono{font-family:var(--f-mono)}
|
||||
.foot-imprint{flex-basis:100%;text-wrap:pretty}.foot-base a{color:inherit;text-decoration:none}.foot-base a:hover{color:var(--ui-hot)}
|
||||
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}
|
||||
</style>
|
||||
<!-- head:end -->
|
||||
<style>
|
||||
/* the explorer pages share this block with block.html and address.html; same tokens as live.html */
|
||||
:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;--blue:#5CB8FF;--green:#3FD39A;
|
||||
--max:1200px;--gutter:clamp(16px,4vw,32px);--sec:clamp(56px,8vw,96px);--head:clamp(40px,6vw,64px);
|
||||
--card-pad:clamp(18px,3vw,28px);--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:24px;--gap-tile:12px;
|
||||
--fs-h1:clamp(32px,5.5vw,56px);--fs-h3:clamp(18px,2vw,22px);--fs-tile:clamp(20px,2.2vw,26px)}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--f-sans);font-size:17px;line-height:1.55;-webkit-font-smoothing:antialiased;overflow-x:hidden}
|
||||
a{color:inherit;text-decoration:none}a:hover{color:var(--molten)}
|
||||
a:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
|
||||
.wrap{max-width:var(--max);margin:0 auto;padding-inline:var(--gutter)}
|
||||
.mono{font-family:var(--f-mono)}
|
||||
.eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash)}
|
||||
h1,h2{font-family:var(--f-display);margin:0;line-height:1.08;text-wrap:balance}
|
||||
h1{font-weight:900;font-size:var(--fs-h1);letter-spacing:-.01em;overflow-wrap:anywhere}
|
||||
h2{font-weight:700;font-size:var(--fs-h3)}
|
||||
p{margin:0}
|
||||
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0;margin-top:var(--gap)}
|
||||
.head{padding-block:var(--head) var(--gap);display:flex;flex-direction:column;gap:14px}
|
||||
.head p{color:var(--ink-2);max-width:64ch;font-size:16px}
|
||||
.search{display:flex;gap:10px;max-width:760px;margin-top:4px}
|
||||
.search input{flex:1;min-width:0;min-height:48px;padding:10px 14px;border-radius:10px;border:1px solid var(--line-2);background:var(--graphite);color:var(--bone);font:500 15px/1.3 var(--f-mono)}
|
||||
.search input::placeholder{color:var(--ash)}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:48px;padding:12px 22px;border-radius:10px;font-weight:600;font-size:16px;border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;font-family:var(--f-sans)}
|
||||
.btn.primary{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)}
|
||||
.btn.primary:hover{background:#FF6A2B;border-color:#FF6A2B;color:var(--ember-ink)}
|
||||
.msg{font-family:var(--f-mono);font-size:13px;color:var(--molten);min-height:18px}
|
||||
.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)}
|
||||
@media (max-width:700px){.strip{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.cell .k{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.cell .v{font-family:var(--f-display);font-weight:700;font-size:var(--fs-tile);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cell .v small{font-family:var(--f-mono);font-weight:500;font-size:13px;color:var(--ash);margin-left:4px;letter-spacing:.04em}
|
||||
@media (max-width:700px){.cell .v{font-size:19px}}
|
||||
.cell .s{font-family:var(--f-mono);font-size:12px;color:var(--ash);text-wrap:balance}
|
||||
.cell.status .v{display:flex;align-items:center;gap:10px}
|
||||
.dot{width:10px;height:10px;border-radius:50%;background:var(--molten);display:inline-block;flex:0 0 10px;animation:pulse 2s ease-in-out infinite}
|
||||
.off .dot{background:var(--ash);animation:none}.off .cell.status .v{color:var(--ash)}.on .cell.status .v{color:var(--molten)}
|
||||
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 8px rgba(255,179,92,0)}}
|
||||
@media (prefers-reduced-motion:reduce){.dot{animation:none}}
|
||||
.viz-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px 20px;margin-bottom:12px}
|
||||
.tbl{overflow-x:auto;margin-top:12px;-webkit-overflow-scrolling:touch}
|
||||
table{border-collapse:collapse;width:100%;font-size:14px}
|
||||
th,td{padding:10px 10px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap;vertical-align:top}
|
||||
th{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500}
|
||||
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}
|
||||
tr:last-child td{border-bottom:0}
|
||||
tbody tr:hover td{background:rgba(255,255,255,.025)}
|
||||
.id{font-family:var(--f-mono);color:var(--molten)}
|
||||
.hash{font-family:var(--f-mono)}
|
||||
.sw{width:10px;height:10px;border-radius:3px;display:inline-block;margin-right:7px;vertical-align:middle;border:1.5px solid var(--ash)}
|
||||
.sw.chain{background:var(--ember);border-color:var(--blue)}.sw.blue{background:rgba(92,184,255,.7);border-color:var(--blue)}.sw.red{background:linear-gradient(45deg,transparent 44%,var(--ash) 44%,var(--ash) 56%,transparent 56%) center/8px 8px no-repeat}
|
||||
.chip{display:inline-block;font-family:var(--f-mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;padding:3px 8px;border-radius:999px;border:1px solid var(--line-2);color:var(--ink-2);margin-right:6px}
|
||||
.chip.hot{border-color:var(--molten);color:var(--molten)}.chip.ok{border-color:var(--green);color:var(--green)}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:6px 16px;margin-top:14px;font-size:13px;color:var(--ink-2)}
|
||||
.legend span{display:inline-flex;align-items:center;white-space:nowrap}
|
||||
.empty{color:var(--ash);font-size:14px;padding:12px 0}
|
||||
.note{font-size:13px;color:var(--ash);margin-top:14px;max-width:80ch}
|
||||
dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:8px 20px;margin:12px 0 0;font-size:14px}
|
||||
dt{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);padding-top:3px}
|
||||
dd{margin:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}
|
||||
@media (max-width:640px){dl{grid-template-columns:1fr;gap:2px 0}dd{margin-bottom:8px}}
|
||||
.two{display:grid;gap:var(--gap);grid-template-columns:1fr}
|
||||
@media (min-width:900px){.two{grid-template-columns:1fr 1fr}.two .card{margin-top:0}}
|
||||
.two-wrap{margin-top:var(--gap)}
|
||||
main{padding-bottom:var(--sec)}
|
||||
.foot{margin-top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- nav:start -->
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<nav class="nav" aria-label="Main">
|
||||
<div class="wrap">
|
||||
<a href="/" class="brand" aria-label="Igneum home">
|
||||
<svg class="mark" viewBox="0 0 1024 1024" width="36" height="36" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg>
|
||||
<span class="word">IGNEUM</span>
|
||||
</a>
|
||||
<button class="burger" id="nav-burger" type="button" aria-expanded="false" aria-controls="nav-links" aria-label="Menu"><svg class="bars" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"></path></svg><svg class="x" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"></path></svg></button>
|
||||
<div class="links" id="nav-links">
|
||||
<a href="/litepaper" data-nav="litepaper">Litepaper</a>
|
||||
<a href="/live" data-nav="live" aria-current="page">Live devnet</a>
|
||||
<a href="/bench" data-nav="bench">Engineering log</a>
|
||||
<a href="/miner" data-nav="miner">Miner</a>
|
||||
<a href="/wallet" data-nav="wallet">Wallet</a>
|
||||
<a href="/evidence" data-nav="evidence">Evidence</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener">GitHub</a>
|
||||
<a href="/miner" class="cta">The miner</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<script>
|
||||
(function(){
|
||||
// the menu: open on the button, closed by any link, a tap outside, Escape (focus back on the button) or a resize past 940 px
|
||||
var b=document.getElementById('nav-burger'),l=document.getElementById('nav-links');if(!b||!l)return;
|
||||
function set(o){l.classList.toggle('open',o);b.setAttribute('aria-expanded',o?'true':'false');}
|
||||
b.addEventListener('click',function(){set(!l.classList.contains('open'));});
|
||||
l.addEventListener('click',function(e){if(e.target.closest('a'))set(false);});
|
||||
document.addEventListener('click',function(e){if(l.classList.contains('open')&&!l.contains(e.target)&&!b.contains(e.target))set(false);});
|
||||
document.addEventListener('keydown',function(e){if(e.key==='Escape'&&l.classList.contains('open')){set(false);b.focus();}});
|
||||
var mq=window.matchMedia('(min-width:941px)');if(mq.addEventListener)mq.addEventListener('change',function(){set(false);});
|
||||
})();
|
||||
</script>
|
||||
<!-- nav:end -->
|
||||
<main id="main" class="wrap">
|
||||
<div class="head">
|
||||
<div class="eyebrow"><a href="/explorer">Explorer</a> · address</div>
|
||||
<h1 class="hash" id="title">Address</h1>
|
||||
<div id="chips"></div>
|
||||
<div class="msg" id="msg" aria-live="polite"></div>
|
||||
</div>
|
||||
<div id="body" hidden>
|
||||
<div class="strip">
|
||||
<div class="cell"><div class="k">Balance</div><div class="v" id="a-balance">n/a</div><div class="s" id="a-balance-s">eth_getBalance</div></div>
|
||||
<div class="cell"><div class="k">Blocks, 24 h</div><div class="v" id="a-blocks">0</div><div class="s" id="a-blocks-s"></div></div>
|
||||
<div class="cell"><div class="k">Earned, 24 h</div><div class="v" id="a-earned">0<small>IGN</small></div><div class="s">80% of each blue block's subsidy</div></div>
|
||||
<div class="cell"><div class="k">Last block</div><div class="v" id="a-last">n/a</div><div class="s" id="a-first"></div></div>
|
||||
</div>
|
||||
<div class="card"><div class="viz-head"><h2>Identity</h2></div><dl id="ident"></dl><p class="note">A miner names its EVM payout address in every coinbase (the IGNA tag) beside the coinbase script's own address, so the two forms name the same miner. The vote keys are the finality identities that signed these blocks; a card runs several.</p></div>
|
||||
<div class="card"><div class="viz-head"><h2>Blocks mined</h2><div class="eyebrow">newest 50 of the last 24 hours</div></div>
|
||||
<div class="tbl"><table><thead><tr><th>Block</th><th class="n">Number</th><th class="n">DAA</th><th class="n">Blue score</th><th>Colour</th><th class="n">Subsidy</th><th class="n">Txs</th><th>Time</th></tr></thead><tbody id="blocks"></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- footer:start -->
|
||||
<footer class="foot">
|
||||
<div class="wrap">
|
||||
<div class="foot-grid">
|
||||
<div class="foot-brand">
|
||||
<a href="/" class="brand" aria-label="Igneum home"><svg class="mark" viewBox="0 0 1024 1024" width="30" height="30" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg><span class="word">IGNEUM</span></a>
|
||||
<p>Mined by GPUs. Proven by fire.</p>
|
||||
</div>
|
||||
<nav class="foot-col" aria-label="Read">
|
||||
<div class="eyebrow">Read</div>
|
||||
<a href="/litepaper">Litepaper</a>
|
||||
<a href="/litepaper#limits">What Igneum does not claim</a>
|
||||
<a href="/litepaper#randomx">Igneum vs RandomX</a>
|
||||
<a href="/litepaper#shoulders">Built on the shoulders</a>
|
||||
<a href="/bench">Engineering log</a>
|
||||
<a href="/evidence">Evidence</a>
|
||||
</nav>
|
||||
<nav class="foot-col" aria-label="Run">
|
||||
<div class="eyebrow">Run</div>
|
||||
<a href="/miner">The miner</a>
|
||||
<a href="/wallet">The wallet</a>
|
||||
<a href="/miners">GPU bench table</a>
|
||||
<a href="/miner#fee">The dev fee</a>
|
||||
<a href="/faucet">Testnet faucet</a>
|
||||
</nav>
|
||||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
<a href="/miner#get">Miner downloads: public testnet</a>
|
||||
<a href="mailto:hello@igneum.network">Report a flaw: hello@igneum.network</a>
|
||||
<a href="https://github.com/igneum-network/spec/issues" rel="noopener">Or open an issue on the spec</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="foot-base">
|
||||
<span class="foot-imprint">Igneum Labs LTD · Unit IH-00-01-01-OF-01, Level 01, Innovation One, Dubai International Financial Centre · <a href="mailto:hello@igneum.network">hello@igneum.network</a></span>
|
||||
<span>© 2026 Igneum. Nothing on this page is an offer to sell anything.</span>
|
||||
<span class="mono">igneum.network</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- footer:end -->
|
||||
<script type="module">
|
||||
import { ign, ignFromWei, shortHash, rel, int, utc } from '/lib/explorer.mjs';
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const link = h => `<a class="hash" href="/block/${esc(h)}" title="${esc(h)}">${esc(shortHash(h, 10, 6))}</a>`;
|
||||
const dl = pairs => pairs.filter(p => p).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join('');
|
||||
const a = decodeURIComponent(location.pathname.split('/').pop() || '');
|
||||
async function load() {
|
||||
const r = await fetch('/api/explorer?address=' + encodeURIComponent(a)); const j = await r.json();
|
||||
$('title').textContent = a;
|
||||
if (!j.ok) { $('msg').textContent = j.error || 'Not found.'; return; }
|
||||
document.title = `Igneum address ${shortHash(a, 8, 6)}`;
|
||||
const now = new Date(j.now).getTime();
|
||||
$('chips').innerHTML = `<span class="chip">${j.kind === 'evm' ? 'EVM payout address' : 'coinbase address'}</span>` + (j.blocks_10m ? '<span class="chip hot">mining now</span>' : '');
|
||||
if (j.balance.available) { $('a-balance').innerHTML = esc(ignFromWei(j.balance.wei, 4)) + '<small>IGN</small>'; $('a-balance-s').textContent = `${j.balance.source}, ${j.network || ''}`; }
|
||||
else { $('a-balance').textContent = 'n/a'; $('a-balance-s').textContent = j.balance.reason; }
|
||||
$('a-blocks').textContent = int(j.blocks_24h); $('a-blocks-s').textContent = `${j.blocks_10m} in 10 min, ${j.blue_24h} blue, ${j.red_24h} red, ${j.chain_24h} on the chain`;
|
||||
$('a-earned').innerHTML = esc(ign(j.earned_24h_sompi, 2)) + '<small>IGN</small>';
|
||||
$('a-last').textContent = j.last_seen ? rel(now - new Date(j.last_seen).getTime()) : 'none in 24 h';
|
||||
$('a-first').textContent = j.first_seen ? 'first in the window ' + rel(now - new Date(j.first_seen).getTime()) : '';
|
||||
$('ident').innerHTML = dl([
|
||||
['EVM address', j.evm_address ? `<a class="hash" href="/address/${esc(j.evm_address)}">${esc(j.evm_address)}</a>` : '<span style="color:var(--ash)">none seen</span>'],
|
||||
['Coinbase address', j.utxo_address ? `<a class="hash" href="/address/${esc(j.utxo_address)}">${esc(j.utxo_address)}</a>` : '<span style="color:var(--ash)">none seen</span>'],
|
||||
['Vote keys', j.miner_ids.length ? j.miner_ids.map(m => `<span class="id">${esc(m.id)}</span> <span style="color:var(--ash)">${m.blocks}</span>`).join(' ') : 'none'],
|
||||
['Earned, 24 h', `${ign(j.earned_24h_sompi, 8)} IGN <span style="color:var(--ash)">${esc(j.earned_note)}</span>`],
|
||||
]);
|
||||
$('blocks').innerHTML = j.recent.length ? j.recent.map(b => `<tr><td><i class="sw ${b.chain ? 'chain' : b.color}"></i>${link(b.hash)}</td><td class="n">${b.number === null ? '<span style="color:var(--ash)">off chain</span>' : int(b.number)}</td><td class="n">${int(b.daa)}</td><td class="n">${int(b.blue_score)}</td><td>${b.chain ? 'chain' : esc(b.color)}</td><td class="n">${b.subsidy_sompi ? ign(b.subsidy_sompi, 4) : 'n/a'}</td><td class="n">${b.tx_count ?? 'n/a'}</td><td title="${new Date(b.ts).toISOString()}">${rel(now - b.ts)}</td></tr>`).join('') : '<tr><td colspan="8" class="empty">No blocks from this address in the last 24 hours.</td></tr>';
|
||||
$('body').hidden = false;
|
||||
}
|
||||
load().catch(e => { $('msg').textContent = 'The API did not answer: ' + e.message; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
26
site/api/_neon.mjs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Shared by the public read endpoints (stats, supply, explorer): Neon's HTTP SQL endpoint over Node's fetch, the
|
||||
// same pattern as live.mjs. Returns rows. A file starting with an underscore is not deployed as a function by Vercel.
|
||||
export function neon(url = process.env.DATABASE_URL) {
|
||||
if (!url) throw new Error('DATABASE_URL is not set');
|
||||
const host = new URL(url).hostname.replace('-pooler', '');
|
||||
return async (query, params = []) => {
|
||||
const r = await fetch(`https://${host}/sql`, {
|
||||
method: 'POST',
|
||||
headers: { 'Neon-Connection-String': url, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, params }),
|
||||
});
|
||||
const j = await r.json();
|
||||
if (!r.ok) throw new Error(j.message || JSON.stringify(j));
|
||||
return j.rows || [];
|
||||
};
|
||||
}
|
||||
export const num = v => (v === null || v === undefined ? null : Number(v));
|
||||
export const tablePrefix = () => (process.env.LIVE_TABLE_PREFIX || '').replace(/[^a-z0-9_]/gi, '');
|
||||
/** EVM chain id from the network name (docs/design/execution-layer.md 8.1: 4461 mainnet, 4462 testnet, 4463 devnet). */
|
||||
export function chainIdOf(network) {
|
||||
const n = String(network || '');
|
||||
if (n.includes('devnet')) return 4463;
|
||||
if (n.includes('testnet')) return 4462;
|
||||
if (n.includes('simnet')) return null;
|
||||
return 4461;
|
||||
}
|
||||
152
site/api/explorer.mjs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
// Igneum DAG explorer, server half. GET /api/explorer with one of:
|
||||
// ?blocks=N[&before=<ms>] the latest N blocks (10 to 200, default 50), older than `before` when given
|
||||
// ?block=<hash> one block: row, header detail, parents, mergeset, transactions, proof records, shards,
|
||||
// checkpoint and certificate when it is one, children, the chain block that merged it
|
||||
// ?height=N the chain block with that number (the EVM block number), same shape as ?block
|
||||
// ?address=<0x.. | igneum..:bech32> blocks mined, 24-hour and 10-minute counts, what those blocks earned, and the
|
||||
// balance through eth_getBalance when EXPLORER_EVM_RPC names a reachable EVM JSON-RPC
|
||||
// ?search=<q> routes a hash, a height or an address to its page, or says what it is not
|
||||
// Everything is read from what tools/observer wrote to Neon; the only live call is the balance. Cached 5 s.
|
||||
import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs';
|
||||
import { classify } from '../lib/explorer.mjs';
|
||||
|
||||
const STALE_AFTER_S = 30;
|
||||
const ROW = `hash, number, blue_score, daa_score, timestamp_ms, parents, parent_hashes, is_chain_block, vote_key_hash, miner_address, evm_miner, engine,
|
||||
tx_count, proof_records, subsidy_sompi, paid_sompi, selected_parent, color, received_at`;
|
||||
|
||||
function row(b) {
|
||||
return {
|
||||
hash: b.hash, number: num(b.number), blue_score: num(b.blue_score), daa: num(b.daa_score), ts: num(b.timestamp_ms), rx: new Date(b.received_at).getTime(),
|
||||
parents: num(b.parents), parent_hashes: b.parent_hashes || [], chain: !!b.is_chain_block, color: b.color === 'blue' || b.color === 'red' ? b.color : 'pending',
|
||||
miner_id: b.vote_key_hash ? String(b.vote_key_hash).slice(0, 8) : null, vote_key_hash: b.vote_key_hash || null, miner: b.miner_address || null, evm_miner: b.evm_miner || null, engine: b.engine || null,
|
||||
tx_count: num(b.tx_count), proof_records: num(b.proof_records), subsidy_sompi: b.subsidy_sompi === null || b.subsidy_sompi === undefined ? null : String(b.subsidy_sompi),
|
||||
paid_sompi: b.paid_sompi === null || b.paid_sompi === undefined ? null : String(b.paid_sompi), selected_parent: b.selected_parent || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function evmCall(url, method, params) {
|
||||
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), signal: AbortSignal.timeout(4000) });
|
||||
const j = await r.json();
|
||||
if (j.error) throw new Error(j.error.message || JSON.stringify(j.error));
|
||||
return j.result;
|
||||
}
|
||||
|
||||
export function createHandler({ env = process.env, sql, evm = evmCall } = {}) {
|
||||
return async function handler(req, res) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=5, s-maxage=5');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); return res.status(405).json({ ok: false, error: 'method not allowed' }); }
|
||||
const q = new URL(req.url || '/', 'http://x').searchParams;
|
||||
const T = tablePrefix();
|
||||
const EVM = env.EXPLORER_EVM_RPC || null;
|
||||
try {
|
||||
sql = sql || neon(env.DATABASE_URL);
|
||||
const head = await sql(`SELECT now() AS now, (SELECT row_to_json(s) FROM ${T}live_state s WHERE s.id = 1) AS state`);
|
||||
const now = new Date(head[0].now).getTime(); const s = head[0].state || null;
|
||||
const updated = s && s.updated_at ? new Date(s.updated_at).getTime() : null;
|
||||
const base = { ok: true, now: new Date(now).toISOString(), network: s ? s.network : null, chain_id: s ? chainIdOf(s.network) : null, stale: updated === null || (now - updated) / 1000 > STALE_AFTER_S, evm_rpc_configured: !!EVM };
|
||||
|
||||
// ---- search ----
|
||||
if (q.has('search')) {
|
||||
const c = classify(q.get('search'));
|
||||
if (c.type === 'none') return res.status(404).json({ ...base, ok: false, error: 'Not a block hash, a transaction hash, a chain block number or an address.' });
|
||||
if (c.type === 'address') return res.status(200).json({ ...base, type: 'address', href: `/address/${c.value}` });
|
||||
if (c.type === 'height') {
|
||||
const r = await sql(`SELECT hash FROM ${T}live_blocks WHERE number = $1 LIMIT 1`, [c.value]);
|
||||
if (!r.length) return res.status(404).json({ ...base, ok: false, error: `No chain block numbered ${c.value} in the last 24 hours.` });
|
||||
return res.status(200).json({ ...base, type: 'block', href: `/block/${r[0].hash}` });
|
||||
}
|
||||
const b = await sql(`SELECT hash FROM ${T}live_blocks WHERE hash = $1`, [c.value]);
|
||||
if (b.length) return res.status(200).json({ ...base, type: 'block', href: `/block/${c.value}` });
|
||||
const t = await sql(`SELECT hash FROM ${T}live_blocks WHERE tx_count > 0 AND detail->'txs' @> $1::jsonb LIMIT 1`, [JSON.stringify([{ hash: '0x' + c.value }])]);
|
||||
if (t.length) return res.status(200).json({ ...base, type: 'tx', href: `/block/${t[0].hash}#tx-${c.value}` });
|
||||
return res.status(404).json({ ...base, ok: false, error: 'No block or transaction with that hash in the last 24 hours.' });
|
||||
}
|
||||
|
||||
// ---- latest blocks ----
|
||||
if (q.has('blocks') || (!q.has('block') && !q.has('height') && !q.has('address'))) {
|
||||
const n = Math.max(10, Math.min(200, Number(q.get('blocks')) || 50));
|
||||
const before = Number(q.get('before')) || 0;
|
||||
const rows = before
|
||||
? await sql(`SELECT ${ROW} FROM ${T}live_blocks WHERE received_at < to_timestamp($2 / 1000.0) ORDER BY received_at DESC LIMIT $1`, [n, before])
|
||||
: await sql(`SELECT ${ROW} FROM ${T}live_blocks ORDER BY received_at DESC LIMIT $1`, [n]);
|
||||
const locked = new Set((await sql(`SELECT hash FROM ${T}live_checkpoints WHERE state = 'locked' ORDER BY index DESC LIMIT 200`).catch(() => [])).map(c => c.hash));
|
||||
return res.status(200).json({ ...base, blocks: rows.map(b => ({ ...row(b), locked: locked.has(b.hash) })) });
|
||||
}
|
||||
|
||||
// ---- one block ----
|
||||
if (q.has('block') || q.has('height')) {
|
||||
let hash = String(q.get('block') || '').replace(/^0x/i, '').toLowerCase();
|
||||
if (q.has('height')) {
|
||||
const r = await sql(`SELECT hash FROM ${T}live_blocks WHERE number = $1 LIMIT 1`, [Number(q.get('height'))]);
|
||||
if (!r.length) return res.status(404).json({ ...base, ok: false, error: `No chain block numbered ${q.get('height')} in the last 24 hours.` });
|
||||
hash = r[0].hash;
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/.test(hash)) return res.status(400).json({ ...base, ok: false, error: 'A block hash is 64 hex characters.' });
|
||||
const rows = await sql(`SELECT ${ROW}, detail FROM ${T}live_blocks WHERE hash = $1`, [hash]);
|
||||
if (!rows.length) return res.status(404).json({ ...base, ok: false, error: 'No block with that hash in the last 24 hours. The explorer keeps one day; older blocks live in the node.' });
|
||||
const b = rows[0];
|
||||
const [shards, cps, certs, children, mergedBy] = await Promise.all([
|
||||
sql(`SELECT shard, shards, state, prover, lag_daa, payout_wei, pgas, carried_by, carrier_number FROM ${T}live_proofs WHERE block_hash = $1 ORDER BY shard`, [hash]).catch(() => []),
|
||||
sql(`SELECT index, state, signed_weight, active_weight, total_weight, fraction_active, fraction_total, votes_seen, voters, locked_at FROM ${T}live_checkpoints WHERE hash = $1`, [hash]).catch(() => []),
|
||||
sql(`SELECT index, hash, carrier, voter_count, aggregator, total_weight, active_weight, prev_index, prev_hash, headers_complete, created_at FROM ${T}live_certificates WHERE hash = $1 OR carrier = $1`, [hash]).catch(() => []),
|
||||
sql(`SELECT hash FROM ${T}live_blocks WHERE received_at >= $2::timestamptz - interval '10 seconds' AND received_at < $2::timestamptz + interval '10 minutes' AND $1 = ANY(parent_hashes) ORDER BY received_at LIMIT 20`, [hash, b.received_at]),
|
||||
b.is_chain_block ? Promise.resolve([]) : sql(`SELECT hash FROM ${T}live_blocks WHERE is_chain_block AND received_at >= $2::timestamptz - interval '10 seconds' AND received_at < $2::timestamptz + interval '10 minutes' AND (detail->'mergeset'->'blues' ? $1 OR detail->'mergeset'->'reds' ? $1) LIMIT 1`, [hash, b.received_at]),
|
||||
]);
|
||||
let evmView = null; // the EVM's view of a chain block, when the deployment has an EVM RPC
|
||||
if (EVM && b.is_chain_block) {
|
||||
try {
|
||||
const e = await evm(EVM, 'eth_getBlockByHash', ['0x' + hash, true]);
|
||||
if (e) evmView = { number: parseInt(e.number, 16), gas_used: parseInt(e.gasUsed, 16), base_fee_per_gas: e.baseFeePerGas, state_root: e.stateRoot, miner: e.miner,
|
||||
transactions: (e.transactions || []).map(t => ({ hash: t.hash, from: t.from, to: t.to, value: t.value, nonce: parseInt(t.nonce, 16), gas: parseInt(t.gas, 16) })),
|
||||
igneum: e.igneum ? { rewards: e.igneum.rewards, proving_pool_credit: e.igneum.provingPoolCredit, pgas_used: e.igneum.pgasUsed, skipped: e.igneum.skipped } : null };
|
||||
} catch (err) { evmView = { error: String(err.message || err).slice(0, 120) }; }
|
||||
}
|
||||
return res.status(200).json({
|
||||
...base, block: { ...row(b), detail: b.detail || null }, shards: shards.map(x => ({ i: num(x.shard), n: num(x.shards), state: x.state, prover: x.prover, lag: num(x.lag_daa), payout_wei: x.payout_wei, pgas: num(x.pgas), carried_by: x.carried_by, carrier_number: num(x.carrier_number) })),
|
||||
checkpoint: cps[0] ? { index: num(cps[0].index), state: cps[0].state, signed: num(cps[0].signed_weight), active: num(cps[0].active_weight), total: num(cps[0].total_weight), fraction_active: num(cps[0].fraction_active), fraction_total: num(cps[0].fraction_total), votes: num(cps[0].votes_seen), voters: num(cps[0].voters), locked_at: cps[0].locked_at } : null,
|
||||
certificates: certs.map(c => ({ index: num(c.index), checkpoint: c.hash, carrier: c.carrier, role: c.hash === hash ? 'checkpoint' : 'carrier', voter_count: num(c.voter_count), aggregator: c.aggregator, total_weight: num(c.total_weight), active_weight: num(c.active_weight), previous: c.prev_index === null ? null : { index: num(c.prev_index), hash: c.prev_hash }, headers_complete: !!c.headers_complete })),
|
||||
children: children.map(c => c.hash), merged_by: b.is_chain_block ? hash : (mergedBy[0] ? mergedBy[0].hash : null), evm: evmView,
|
||||
});
|
||||
}
|
||||
|
||||
// ---- address ----
|
||||
if (q.has('address')) {
|
||||
const c = classify(q.get('address'));
|
||||
if (c.type !== 'address') return res.status(400).json({ ...base, ok: false, error: 'An address is 0x plus 40 hex characters, or an igneum bech32 address.' });
|
||||
const col = c.kind === 'evm' ? 'evm_miner' : 'miner_address';
|
||||
const [sum, recent, ids] = await Promise.all([
|
||||
sql(`SELECT count(*)::int AS blocks_24h, count(*) FILTER (WHERE received_at > now() - interval '10 minutes')::int AS blocks_10m,
|
||||
count(*) FILTER (WHERE color = 'blue')::int AS blue_24h, count(*) FILTER (WHERE color = 'red')::int AS red_24h, count(*) FILTER (WHERE is_chain_block)::int AS chain_24h,
|
||||
min(timestamp_ms) AS first_ts, max(timestamp_ms) AS last_ts,
|
||||
coalesce(sum(subsidy_sompi) FILTER (WHERE color = 'blue'), 0)::text AS blue_subsidy_sompi,
|
||||
max(evm_miner) AS evm_miner, max(miner_address) AS miner_address
|
||||
FROM ${T}live_blocks WHERE ${col} = $1`, [c.value]),
|
||||
sql(`SELECT ${ROW} FROM ${T}live_blocks WHERE ${col} = $1 ORDER BY received_at DESC LIMIT 50`, [c.value]),
|
||||
sql(`SELECT vote_key_hash, count(*)::int AS n FROM ${T}live_blocks WHERE ${col} = $1 AND vote_key_hash IS NOT NULL GROUP BY 1 ORDER BY n DESC LIMIT 32`, [c.value]),
|
||||
]);
|
||||
const t = sum[0];
|
||||
const evmAddress = c.kind === 'evm' ? c.value : (t.evm_miner || null);
|
||||
let balance = { available: false, reason: EVM ? 'no EVM address known for this coinbase address' : 'no EVM RPC configured on this deployment (EXPLORER_EVM_RPC)' };
|
||||
if (EVM && evmAddress) {
|
||||
try { const wei = await evm(EVM, 'eth_getBalance', [evmAddress, 'latest']); balance = { available: true, wei: BigInt(wei).toString(), address: evmAddress, source: 'eth_getBalance latest' }; }
|
||||
catch (err) { balance = { available: false, reason: String(err.message || err).slice(0, 120) }; }
|
||||
}
|
||||
// what the blocks earned: each blue block pays 80% of the subsidy its payload declares to this miner, through the coinbase of the block that merges it
|
||||
const blue = BigInt(t.blue_subsidy_sompi || 0); const earned = blue * 80n / 100n;
|
||||
return res.status(200).json({
|
||||
...base, address: c.value, kind: c.kind, evm_address: evmAddress, utxo_address: c.kind === 'utxo' ? c.value : (t.miner_address || null),
|
||||
blocks_24h: t.blocks_24h, blocks_10m: t.blocks_10m, blue_24h: t.blue_24h, red_24h: t.red_24h, chain_24h: t.chain_24h,
|
||||
first_seen: t.first_ts ? new Date(num(t.first_ts)).toISOString() : null, last_seen: t.last_ts ? new Date(num(t.last_ts)).toISOString() : null,
|
||||
earned_24h_sompi: earned.toString(), earned_note: '80% of the declared subsidy of every blue block this address mined in the last 24 hours, paid by the coinbase of the block that merged each one (coinbase.rs); red blocks pay the merging miner instead',
|
||||
miner_ids: ids.map(i => ({ id: String(i.vote_key_hash).slice(0, 8), blocks: i.n })), balance, recent: recent.map(row),
|
||||
});
|
||||
}
|
||||
return res.status(400).json({ ...base, ok: false, error: 'blocks, block, height, address or search' });
|
||||
} catch (e) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.status(500).json({ ok: false, error: String(e.message || e) });
|
||||
}
|
||||
};
|
||||
}
|
||||
export default createHandler();
|
||||
73
site/api/public-stats.test.mjs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// The public stats contract: /api/stats and /api/supply answer with every field docs/api/public-stats.md documents
|
||||
// (FIELDS in each handler), computed from a fixture of what the observer writes. Runs without a database.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHandler as stats, FIELDS as STATS_FIELDS, REWARD_FIELDS, LAST_BLOCK_FIELDS, rewardAt } from './stats.mjs';
|
||||
import { createHandler as supply, FIELDS as SUPPLY_FIELDS, SCHEDULE_FIELDS } from './supply.mjs';
|
||||
|
||||
const NOW = '2026-10-05T19:21:56.000Z';
|
||||
const state = {
|
||||
id: 1, block_count: 125066, header_count: 125066, blue_score: 122211, difficulty: 7748714.75, hashes_per_second_estimate: 27759288, peers: 7, mempool: 0,
|
||||
node_version: '2.1.0', network: 'igneum-devnet', blocks_60s: 58, blocks_per_minute: Array.from({ length: 60 }, (_, i) => [1791227000000 + i * 60000, 60]),
|
||||
observer_started_at: '2026-10-05T19:01:00.000Z', updated_at: '2026-10-05T19:21:55.000Z',
|
||||
finality: { chain_id: 'igneum-devnet', finality_active: true, latest_locked_index: 4073, latest_locked_blue_score: 122190 },
|
||||
supply_check: { checked_at: NOW, sampled: 500, rule_match: 500, rule_mismatch: 0, sum_match: 480, sum_mismatch: 0, sum_skipped: 20, examples: [], bps: 1 },
|
||||
};
|
||||
const last = { hash: '2622db7698c6825b370bbffba03ea11bba250792040296971d2b790a954203ee', blue_score: 122211, daa_score: 125064, timestamp_ms: 1791227608731,
|
||||
miner_address: 'igneumdev:qr4yfyf9mzn643fj8faksflmxur0wxgtmpg8y7fa6qkm8pcjh3ngzqedfx47e', vote_key_hash: '1c3f1190b777365e419f02fd46ce365f464377d0fdbdf1332da6600d09a75a0e', tx_count: 0, is_chain_block: true, number: 81263 };
|
||||
function fakeSql(rows) { return async (query) => { if (/row_to_json/.test(query) && /max\(daa_score\)/.test(query)) return [{ now: NOW, state, daa: 125064 }]; if (/row_to_json/.test(query)) return [{ now: NOW, state }]; if (/ORDER BY received_at DESC LIMIT 1/.test(query)) return [last]; if (/count\(DISTINCT vote_key_hash\)/.test(query)) return [{ n: 25 }]; return rows || []; }; }
|
||||
function fakeRes() { const r = { code: 0, headers: {}, body: null, status(c) { r.code = c; return r; }, setHeader(k, v) { r.headers[k] = v; }, json(o) { r.body = o; return r; } }; return r; }
|
||||
|
||||
test('/api/stats carries every documented field and the reward of spec 2.5', async () => {
|
||||
const res = fakeRes();
|
||||
await stats({ sql: fakeSql(), env: {} })({ method: 'GET', url: '/api/stats' }, res);
|
||||
assert.equal(res.code, 200);
|
||||
for (const f of STATS_FIELDS) assert.ok(f in res.body, `missing ${f}`);
|
||||
for (const f of REWARD_FIELDS) assert.ok(f in res.body.block_reward, `missing block_reward.${f}`);
|
||||
for (const f of LAST_BLOCK_FIELDS) assert.ok(f in res.body.last_block, `missing last_block.${f}`);
|
||||
assert.equal(res.body.chain_id, 4463);
|
||||
assert.equal(res.body.height, 81263);
|
||||
assert.equal(res.body.block_reward.sompi, '454486399'); // E(125,064), the payload of block 2622db76
|
||||
assert.equal(res.body.block_reward.ign, '4.54486399');
|
||||
assert.equal(res.body.block_reward.miner_ign, '3.6358912'); // 80%: 454486399 - 90897279
|
||||
assert.equal(res.body.block_reward.proving_pool_ign, '0.90897279');
|
||||
assert.equal(res.body.block_time_measured_s, 1.034);
|
||||
assert.equal(res.body.blocks_per_day_measured, 86400);
|
||||
assert.equal(res.body.stale, false);
|
||||
assert.equal(res.headers['Cache-Control'], 'public, max-age=10, s-maxage=10');
|
||||
});
|
||||
test('/api/stats marks a stale observer', async () => {
|
||||
const res = fakeRes();
|
||||
const old = { ...state, updated_at: '2026-10-05T19:00:00.000Z' };
|
||||
const sql = async (q) => (/row_to_json/.test(q) ? [{ now: NOW, state: old }] : /LIMIT 1/.test(q) ? [last] : [{ n: 0 }]);
|
||||
await stats({ sql, env: {} })({ method: 'GET', url: '/api/stats' }, res);
|
||||
assert.equal(res.body.stale, true);
|
||||
assert.ok(res.body.age_s > 1000);
|
||||
});
|
||||
test('rewardAt: halving and ramp bookkeeping', () => {
|
||||
const r = rewardAt(63_115_200);
|
||||
assert.equal(r.sompi, '1584404390'); assert.equal(r.halving_period, 1); assert.equal(r.next_halving_daa, 126_230_400); assert.equal(r.ramp_factor, 1);
|
||||
assert.equal(rewardAt(0).ramp_factor, 0.1);
|
||||
});
|
||||
test('/api/supply carries every documented field, the cap and a 33-row schedule', async () => {
|
||||
const res = fakeRes();
|
||||
await supply({ sql: fakeSql(), env: {} })({ method: 'GET', url: '/api/supply' }, res);
|
||||
assert.equal(res.code, 200);
|
||||
for (const f of SUPPLY_FIELDS) assert.ok(f in res.body, `missing ${f}`);
|
||||
assert.equal(res.body.max_supply_ign, '4000000000');
|
||||
assert.equal(res.body.schedule.length, 33);
|
||||
for (const f of SCHEDULE_FIELDS) assert.ok(f in res.body.schedule[0], `missing schedule.${f}`);
|
||||
assert.equal(res.body.schedule[0].per_second_ign, '31.68808781');
|
||||
assert.equal(res.body.schedule[1].per_second_ign, '15.8440439');
|
||||
assert.equal(res.body.schedule[1].start_daa, 63115200);
|
||||
assert.equal(res.body.minted_at_end_ign, '3963038988.86765648');
|
||||
assert.equal(res.body.circulating_ign, '482350.69732271'); // mintedByRule(125,064): the rule summed over DAA 0 to 125,063
|
||||
assert.equal(res.body.halving.next_halving_daa, 63115200);
|
||||
assert.equal(res.body.ramp.complete, false);
|
||||
assert.equal(res.body.check.rule_match, 500);
|
||||
});
|
||||
test('both refuse anything but GET', async () => {
|
||||
for (const h of [stats({ sql: fakeSql(), env: {} }), supply({ sql: fakeSql(), env: {} })]) {
|
||||
const res = fakeRes(); await h({ method: 'POST', url: '/' }, res); assert.equal(res.code, 405);
|
||||
}
|
||||
});
|
||||
90
site/api/stats.mjs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Igneum public network stats. GET /api/stats: the numbers profitability sites and pool software read (WhatToMine's
|
||||
// listing form asks for an explorer or pool with an API and a source for the block reward and the block time).
|
||||
// Read from what tools/observer wrote to Neon (live_state, newest live_blocks row); the reward is computed from the
|
||||
// emission rule (site/lib/emission.mjs, spec 2.5) at the node's DAA score. Cached 10 s at the edge. Documented with
|
||||
// example responses in docs/api/public-stats.md; FIELDS below is the contract the CI check asserts.
|
||||
import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs';
|
||||
import { blockSubsidy, rampFactor, HALVING_INTERVAL_SECONDS, LAUNCH_RAMP_SECONDS, PROVING_POOL_SHARE_PERCENT, sompiToIgn } from '../lib/emission.mjs';
|
||||
|
||||
export const STALE_AFTER_S = 30;
|
||||
export const FIELDS = ['ok', 'now', 'network', 'chain_id', 'node_version', 'algorithm', 'stale', 'age_s', 'height', 'block_count', 'header_count', 'daa', 'blue_score',
|
||||
'difficulty', 'hashrate', 'hashrate_unit', 'hashrate_source', 'block_time_target_s', 'block_time_measured_s', 'blocks_per_day_target', 'blocks_per_day_measured',
|
||||
'block_reward', 'last_block', 'finality', 'peers', 'mempool', 'miners_10m', 'observer_updated_at', 'source'];
|
||||
export const REWARD_FIELDS = ['sompi', 'ign', 'miner_ign', 'proving_pool_ign', 'split', 'daa_used', 'ramp_factor', 'halving_period', 'next_halving_daa', 'next_halving_in_s'];
|
||||
export const LAST_BLOCK_FIELDS = ['hash', 'time', 'ts_ms', 'age_s', 'blue_score', 'daa', 'miner', 'miner_id', 'tx_count', 'chain'];
|
||||
|
||||
export function rewardAt(daa) {
|
||||
const sompi = blockSubsidy(daa, 1);
|
||||
const pool = sompi * PROVING_POOL_SHARE_PERCENT / 100n;
|
||||
const period = Math.floor(daa / Number(HALVING_INTERVAL_SECONDS));
|
||||
const nextHalving = (period + 1) * Number(HALVING_INTERVAL_SECONDS);
|
||||
return {
|
||||
sompi: sompi.toString(), ign: sompiToIgn(sompi), miner_ign: sompiToIgn(sompi - pool), proving_pool_ign: sompiToIgn(pool),
|
||||
split: '80% block producer, 20% proving pool', daa_used: daa, ramp_factor: Math.round(rampFactor(daa) * 1e6) / 1e6,
|
||||
halving_period: period, next_halving_daa: nextHalving, next_halving_in_s: nextHalving - daa,
|
||||
};
|
||||
}
|
||||
|
||||
export function shape({ now, state: s, last, miners10m }) {
|
||||
const updated = s && s.updated_at ? new Date(s.updated_at).getTime() : null;
|
||||
const age = updated === null ? null : Math.max(0, (now - updated) / 1000);
|
||||
const stale = age === null || age > STALE_AFTER_S;
|
||||
const daa = s ? num(s.daa_score ?? (last && last.daa_score)) : null;
|
||||
const bpm = (s && s.blocks_per_minute) || [];
|
||||
const lastHour = bpm.reduce((a, [, n]) => a + (n || 0), 0);
|
||||
const b60 = s ? num(s.blocks_60s) : null;
|
||||
return {
|
||||
ok: true, now: new Date(now).toISOString(),
|
||||
network: s ? s.network : null, chain_id: s ? chainIdOf(s.network) : null, node_version: s ? s.node_version : null,
|
||||
algorithm: 'Igneum lottery hash: random-program GPU hash, new program every hour, generator v2 (docs/spec/01-lottery-hash.md)',
|
||||
stale, age_s: age === null ? null : Math.round(age * 10) / 10,
|
||||
// height is the chain block number (the EVM block number); block_count counts every DAG block the node holds
|
||||
height: last && last.number !== null && last.number !== undefined ? num(last.number) : null,
|
||||
block_count: s ? num(s.block_count) : null, header_count: s ? num(s.header_count) : null,
|
||||
daa, blue_score: s ? num(s.blue_score) : null, difficulty: s ? num(s.difficulty) : null,
|
||||
hashrate: s ? num(s.hashes_per_second_estimate) : null, hashrate_unit: 'H/s',
|
||||
hashrate_source: 'the node\'s estimateNetworkHashesPerSecond over a 1,000-block window; blue work added per second over 10 min when the node refuses the window',
|
||||
block_time_target_s: 1,
|
||||
block_time_measured_s: b60 ? Math.round(60 / b60 * 1000) / 1000 : null,
|
||||
blocks_per_day_target: 86400,
|
||||
blocks_per_day_measured: bpm.length >= 60 ? lastHour * 24 : null,
|
||||
block_reward: daa === null ? null : rewardAt(daa),
|
||||
last_block: last ? {
|
||||
hash: last.hash, time: new Date(num(last.timestamp_ms)).toISOString(), ts_ms: num(last.timestamp_ms), age_s: Math.round((now - num(last.timestamp_ms)) / 100) / 10,
|
||||
blue_score: num(last.blue_score), daa: num(last.daa_score), miner: last.miner_address || null, miner_id: last.vote_key_hash ? String(last.vote_key_hash).slice(0, 8) : null,
|
||||
tx_count: num(last.tx_count), chain: !!last.is_chain_block,
|
||||
} : null,
|
||||
finality: s && s.finality ? { active: !!s.finality.finality_active, latest_locked_index: num(s.finality.latest_locked_index), latest_locked_blue_score: num(s.finality.latest_locked_blue_score), chain_id: s.finality.chain_id || null } : { active: false, latest_locked_index: null, latest_locked_blue_score: null, chain_id: null },
|
||||
peers: s ? num(s.peers) : null, mempool: s ? num(s.mempool) : null, miners_10m: miners10m,
|
||||
observer_updated_at: s ? s.updated_at : null,
|
||||
source: 'tools/observer reading one node every 2 s; reward from docs/spec/02-consensus.md 2.5 at the node\'s DAA score',
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandler({ env = process.env, sql } = {}) {
|
||||
return async function handler(req, res) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=10, s-maxage=10');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); return res.status(405).json({ ok: false, error: 'method not allowed' }); }
|
||||
try {
|
||||
sql = sql || neon(env.DATABASE_URL);
|
||||
const T = tablePrefix();
|
||||
const [head, lastRows, minerRows] = await Promise.all([
|
||||
sql(`SELECT now() AS now, (SELECT row_to_json(s) FROM ${T}live_state s WHERE s.id = 1) AS state`),
|
||||
sql(`SELECT hash, blue_score, daa_score, timestamp_ms, miner_address, vote_key_hash, tx_count, is_chain_block,
|
||||
(SELECT max(number) FROM ${T}live_blocks WHERE number IS NOT NULL AND received_at > now() - interval '10 minutes') AS number
|
||||
FROM ${T}live_blocks ORDER BY received_at DESC LIMIT 1`),
|
||||
sql(`SELECT count(DISTINCT vote_key_hash)::int AS n FROM ${T}live_blocks WHERE received_at > now() - interval '10 minutes' AND vote_key_hash IS NOT NULL`),
|
||||
]);
|
||||
const now = new Date(head[0].now).getTime();
|
||||
const s = head[0].state || null;
|
||||
const last = lastRows[0] || null;
|
||||
if (s && last && (s.daa_score === undefined)) s.daa_score = last.daa_score; // live_state has no DAA column; the newest block's is the node's
|
||||
return res.status(200).json(shape({ now, state: s, last, miners10m: minerRows[0] ? minerRows[0].n : 0 }));
|
||||
} catch (e) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.status(500).json({ ok: false, error: String(e.message || e) });
|
||||
}
|
||||
};
|
||||
}
|
||||
export default createHandler();
|
||||
76
site/api/supply.mjs
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// Igneum supply. GET /api/supply: coins minted so far by the emission rule at the node's DAA score, the hard cap, the
|
||||
// halving schedule as a table and the 30-day launch ramp (docs/spec/02-consensus.md 2.5, consensus/core/src/igneum.rs),
|
||||
// plus the observer's hourly check of the chain's coinbase sums against that rule (live_state.supply_check). Cached
|
||||
// 10 s at the edge. Documented in docs/api/public-stats.md.
|
||||
import { neon, num, tablePrefix, chainIdOf } from './_neon.mjs';
|
||||
import * as E from '../lib/emission.mjs';
|
||||
|
||||
export const FIELDS = ['ok', 'now', 'network', 'chain_id', 'unit', 'daa', 'max_supply_ign', 'circulating_ign', 'circulating_sompi', 'minted_at_end_ign', 'never_minted_ign',
|
||||
'emission_per_second_ign', 'block_reward_ign', 'halving', 'ramp', 'schedule', 'check', 'rule', 'source', 'note'];
|
||||
export const SCHEDULE_FIELDS = ['period', 'start_daa', 'end_daa', 'years_from_genesis', 'per_second_ign', 'per_block_ign_at_1bps', 'minted_by_end_ign', 'share_of_cap_by_end'];
|
||||
const S = Number(E.SECONDS_PER_YEAR);
|
||||
|
||||
export function shape({ now, state: s, daa }) {
|
||||
const minted = daa === null ? null : E.mintedByRule(daa);
|
||||
const end = E.mintedAtEnd();
|
||||
const period = daa === null ? null : Math.floor(daa / Number(E.HALVING_INTERVAL_SECONDS));
|
||||
const nextHalving = period === null ? null : (period + 1) * Number(E.HALVING_INTERVAL_SECONDS);
|
||||
const ramp = daa === null ? null : Number(E.LAUNCH_RAMP_SECONDS) - daa;
|
||||
const schedule = E.halvingTable().map(r => ({
|
||||
period: r.period, start_daa: Number(r.start_daa), end_daa: Number(r.end_daa),
|
||||
years_from_genesis: `${Number(r.start_daa) / S} to ${Number(r.end_daa) / S}`,
|
||||
per_second_ign: E.sompiToIgn(r.per_second_sompi), per_block_ign_at_1bps: E.sompiToIgn(r.per_second_sompi),
|
||||
minted_by_end_ign: E.sompiToIgn(r.minted_by_end_sompi),
|
||||
share_of_cap_by_end: Math.round(Number(r.minted_by_end_sompi * 1_000_000n / E.SUPPLY_CAP_SOMPI)) / 10_000,
|
||||
}));
|
||||
return {
|
||||
ok: true, now: new Date(now).toISOString(),
|
||||
network: s ? s.network : null, chain_id: s ? chainIdOf(s.network) : null,
|
||||
unit: { symbol: 'IGN', decimals_consensus: 8, decimals_evm: 18, note: 'the coinbase pays in 8-decimal units (open item O-2.6 keeps Kaspa\'s SOMPI_PER_KASPA); the EVM shows the same amount at 18 decimals' },
|
||||
daa,
|
||||
max_supply_ign: E.sompiToIgn(E.SUPPLY_CAP_SOMPI),
|
||||
circulating_ign: minted === null ? null : E.sompiToIgn(minted),
|
||||
circulating_sompi: minted === null ? null : minted.toString(),
|
||||
minted_at_end_ign: E.sompiToIgn(end),
|
||||
never_minted_ign: E.sompiToIgn(E.SUPPLY_CAP_SOMPI - end),
|
||||
emission_per_second_ign: daa === null ? null : E.sompiToIgn(E.blockSubsidy(daa, 1)),
|
||||
block_reward_ign: daa === null ? null : E.sompiToIgn(E.blockSubsidy(daa, 1)),
|
||||
halving: {
|
||||
interval_daa_s: Number(E.HALVING_INTERVAL_SECONDS), interval_years: 2, period, next_halving_daa: nextHalving,
|
||||
next_halving_in_s: nextHalving === null ? null : nextHalving - daa,
|
||||
next_halving_estimate: nextHalving === null ? null : new Date(now + (nextHalving - daa) * 1000).toISOString(),
|
||||
estimate_note: 'the estimate assumes one DAA second per wall-clock second from now',
|
||||
},
|
||||
ramp: {
|
||||
start_percent: Number(E.LAUNCH_RAMP_START_PERCENT), length_daa_s: Number(E.LAUNCH_RAMP_SECONDS), length_days: 30,
|
||||
factor_now: daa === null ? null : Math.round(E.rampFactor(daa) * 1e6) / 1e6, complete: daa === null ? null : daa >= Number(E.LAUNCH_RAMP_SECONDS),
|
||||
remaining_s: ramp === null ? null : Math.max(0, ramp),
|
||||
withheld_ign: E.sompiToIgn(E.SUPPLY_CAP_SOMPI - end), withheld_note: 'the ramp withholds about 37 million IGN that are never minted; integer floors withhold the rest (spec 2.5)',
|
||||
},
|
||||
schedule,
|
||||
check: s && s.supply_check ? s.supply_check : null,
|
||||
rule: 'E(t) = ramp(t) * floor(10^9 * UNIT / 31,557,600) >> floor(t / 63,115,200); ramp(t) = min(1, 1/10 + 9/10 * t / 2,592,000); t = DAA score / bps, bps = 1',
|
||||
source: 'docs/spec/02-consensus.md 2.5; vendor/igneum-node consensus/core/src/igneum.rs block_subsidy and launch_ramp; circulating = the rule summed over every DAA second from 0 to the node\'s DAA score (site/lib/emission.mjs mintedByRule, exact)',
|
||||
note: 'circulating is the schedule at this DAA score. The chain pays E per blue block it merges and per red inside the DAA window, which tracks the schedule one block per DAA step; the devnet of 3 October 2026 ran 4.7x the schedule for eight minutes during a retarget lag (spec 2.5). check reports what the observer measured on the newest blocks.',
|
||||
};
|
||||
}
|
||||
|
||||
export function createHandler({ env = process.env, sql } = {}) {
|
||||
return async function handler(req, res) {
|
||||
res.setHeader('Cache-Control', 'public, max-age=10, s-maxage=10');
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
if (req.method !== 'GET') { res.setHeader('Allow', 'GET'); return res.status(405).json({ ok: false, error: 'method not allowed' }); }
|
||||
try {
|
||||
sql = sql || neon(env.DATABASE_URL);
|
||||
const T = tablePrefix();
|
||||
const rows = await sql(`SELECT now() AS now, (SELECT row_to_json(s) FROM ${T}live_state s WHERE s.id = 1) AS state,
|
||||
(SELECT max(daa_score) FROM ${T}live_blocks WHERE received_at > now() - interval '10 minutes') AS daa`);
|
||||
const now = new Date(rows[0].now).getTime();
|
||||
return res.status(200).json(shape({ now, state: rows[0].state || null, daa: num(rows[0].daa) }));
|
||||
} catch (e) {
|
||||
res.setHeader('Cache-Control', 'no-store');
|
||||
return res.status(500).json({ ok: false, error: String(e.message || e) });
|
||||
}
|
||||
};
|
||||
}
|
||||
export default createHandler();
|
||||
330
site/block.html
Normal file
|
|
@ -0,0 +1,330 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Igneum block</title>
|
||||
<meta name="description" content="One Igneum block: header, parents, mergeset, transactions, proof records and, when it is a checkpoint, its finality certificate.">
|
||||
<link rel="canonical" href="https://igneum.network/block">
|
||||
<meta name="robots" content="noindex">
|
||||
<meta name="theme-color" content="#0C0C0E">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Igneum">
|
||||
<meta property="og:title" content="Igneum block">
|
||||
<meta property="og:description" content="One Igneum block: header, parents, mergeset, transactions, proof records and its finality certificate.">
|
||||
<meta property="og:url" content="https://igneum.network/block">
|
||||
<meta property="og:image" content="https://igneum.network/og-small.png?v=3">
|
||||
<meta property="og:image:width" content="256">
|
||||
<meta property="og:image:height" content="256">
|
||||
<meta property="og:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Igneum block">
|
||||
<meta name="twitter:description" content="One Igneum block: header, parents, mergeset, transactions, proof records and its finality certificate.">
|
||||
<meta name="twitter:image" content="https://igneum.network/og-small.png?v=3">
|
||||
<meta name="twitter:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48">
|
||||
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<!-- head:start -->
|
||||
<link rel="preload" href="/fonts/unbounded-900.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/fonts/plex-sans-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<style>
|
||||
/* Shared by every page. Source: site/partials/head.html, injected by site/build.mjs between the head markers. Edit the partial, not the page. */
|
||||
/* Fonts, self-hosted (latin subsets, OFL): Unbounded 500/700/900, IBM Plex Sans 400/500/600, IBM Plex Mono 400/500. Fallbacks carry size and ascent overrides so the swap does not move the layout. */
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/unbounded-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/unbounded-700.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(/fonts/unbounded-900.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-sans-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-sans-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(/fonts/plex-sans-600.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-mono-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-mono-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded Fallback';src:local('Arial Black'),local('Arial-Black'),local('Impact');size-adjust:114%;ascent-override:87.5%;descent-override:21.5%;line-gap-override:0%}
|
||||
@font-face{font-family:'Plex Sans Fallback';src:local('Arial'),local('Helvetica Neue'),local('Helvetica');size-adjust:100.5%;ascent-override:102%;descent-override:27.4%;line-gap-override:0%}
|
||||
@font-face{font-family:'Plex Mono Fallback';src:local('Courier New'),local('Menlo');size-adjust:100%;ascent-override:102.5%;descent-override:27.5%;line-gap-override:0%}
|
||||
:root{--f-sans:'IBM Plex Sans','Plex Sans Fallback',system-ui,-apple-system,sans-serif;--f-display:'Unbounded','Unbounded Fallback',sans-serif;--f-mono:'IBM Plex Mono','Plex Mono Fallback',ui-monospace,Menlo,monospace;
|
||||
/* site chrome tokens (nav, footer, skip link). Dark by default; the litepaper sets light values and resets these in dark mode */
|
||||
--nav-h:68px;--ui-bg:rgba(12,12,14,.84);--ui-menu:#0C0C0E;--ui-ink:#F4F1EC;--ui-ink-2:#C9C7C2;--ui-ash:#9A9A9E;--ui-line:#2A2A30;--ui-line-2:#3A3A42;--ui-accent:#F2541B;--ui-accent-ink:#0C0C0E;--ui-hot:#FFB35C;--ui-hover:#FF6A2B;--ui-tint:rgba(242,84,27,.12)}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
h1,h2,h3{text-wrap:balance}
|
||||
p,li,dd,figcaption{text-wrap:pretty}
|
||||
a:focus-visible,button:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--ui-accent);outline-offset:3px;border-radius:6px}
|
||||
.skip{position:absolute;left:12px;top:-80px;z-index:50;background:var(--ui-accent);color:var(--ui-accent-ink);padding:10px 14px;border-radius:10px;font:600 15px/1.2 var(--f-sans);text-decoration:none}
|
||||
.skip:focus{top:12px}
|
||||
/* nav: one bar on every page, the mark in its black square, seven links and the miner button, a menu under 941 px (the eight items need 688 px beside the brand at 15 px, so the bar fits from 941 px up without a wrapped label) */
|
||||
.nav{position:sticky;top:0;z-index:20;background:var(--ui-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--ui-line);color:var(--ui-ink)}
|
||||
.nav .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:var(--nav-h)}
|
||||
.nav a{color:inherit;text-decoration:none}
|
||||
.brand{display:inline-flex;align-items:center;gap:10px;color:var(--ui-ink);text-decoration:none;flex:0 0 auto}
|
||||
.brand .mark{display:block;flex:0 0 auto}
|
||||
.brand .word{font-family:var(--f-display);font-weight:900;font-size:20px;letter-spacing:.06em;line-height:1}
|
||||
.nav .links{display:flex;align-items:center;gap:clamp(14px,2vw,26px);font-size:15px;font-weight:500}
|
||||
.nav .links a{color:var(--ui-ink-2);padding:4px 0;border-bottom:2px solid transparent;white-space:nowrap;transition:color .15s ease,border-color .15s ease}
|
||||
.nav .links a:hover,.nav .brand:hover{color:var(--ui-hot);text-decoration:none}
|
||||
.nav .brand:hover{color:var(--ui-ink)}
|
||||
.nav .links a[aria-current="page"]{color:var(--ui-ink);border-bottom-color:var(--ui-accent)}
|
||||
.nav .links a.cta{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:8px 16px;border-radius:10px;font-weight:600;background:var(--ui-accent);color:var(--ui-accent-ink);border:1px solid var(--ui-accent);transition:background .15s ease,transform .15s ease}
|
||||
.nav .links a.cta:hover{background:var(--ui-hover);border-color:var(--ui-hover);color:var(--ui-accent-ink);transform:translateY(-1px)}
|
||||
.burger{display:none;background:none;border:1px solid var(--ui-line-2);color:var(--ui-ink);border-radius:10px;width:44px;height:44px;align-items:center;justify-content:center;cursor:pointer;padding:0;flex:0 0 auto}
|
||||
.burger .x{display:none}.burger[aria-expanded="true"] .x{display:block}.burger[aria-expanded="true"] .bars{display:none}
|
||||
@media (max-width:940px){
|
||||
.nav .links{display:none;position:absolute;left:0;right:0;top:100%;background:var(--ui-menu);border-bottom:1px solid var(--ui-line);box-shadow:0 24px 40px rgba(0,0,0,.35);padding:10px var(--gutter,16px) 18px;flex-direction:column;align-items:stretch;gap:2px}
|
||||
.nav .links a{padding:13px 10px;border-radius:10px;border-bottom:0;font-size:16px}
|
||||
.nav .links a[aria-current="page"]{background:var(--ui-tint)}
|
||||
.nav .links a.cta{margin-top:8px;min-height:48px}
|
||||
.nav .links.open{display:flex}
|
||||
.burger{display:inline-flex}
|
||||
}
|
||||
/* footer, the same on every page */
|
||||
.foot{border-top:1px solid var(--ui-line);color:var(--ui-ink);margin-top:var(--sec,64px)}
|
||||
.foot .wrap{padding-block:48px 32px}
|
||||
.foot-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,200px),1fr));gap:32px}
|
||||
.foot-brand{display:flex;flex-direction:column;gap:12px;max-width:34ch}
|
||||
.foot-brand .brand{align-self:flex-start}.foot-brand .word{font-size:16px}
|
||||
.foot-brand p{color:var(--ui-ash);font-size:14px;margin:0}
|
||||
.foot-col{display:flex;flex-direction:column;gap:10px;font-size:15px}
|
||||
.foot-col .eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ui-ash);margin-bottom:4px}
|
||||
.foot-col a{color:var(--ui-ink-2);text-decoration:none;display:inline-flex;align-items:center;gap:6px;align-self:flex-start}
|
||||
.foot-col a:hover{color:var(--ui-hot);text-decoration:none}
|
||||
.foot-base{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px 24px;margin-top:36px;padding-top:20px;border-top:1px solid var(--ui-line);font-size:13px;color:var(--ui-ash)}
|
||||
.foot-base span{text-wrap:balance}.foot-base .mono{font-family:var(--f-mono)}
|
||||
.foot-imprint{flex-basis:100%;text-wrap:pretty}.foot-base a{color:inherit;text-decoration:none}.foot-base a:hover{color:var(--ui-hot)}
|
||||
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}
|
||||
</style>
|
||||
<!-- head:end -->
|
||||
<style>
|
||||
/* the explorer pages share this block with block.html and address.html; same tokens as live.html */
|
||||
:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;--blue:#5CB8FF;--green:#3FD39A;
|
||||
--max:1200px;--gutter:clamp(16px,4vw,32px);--sec:clamp(56px,8vw,96px);--head:clamp(40px,6vw,64px);
|
||||
--card-pad:clamp(18px,3vw,28px);--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:24px;--gap-tile:12px;
|
||||
--fs-h1:clamp(32px,5.5vw,56px);--fs-h3:clamp(18px,2vw,22px);--fs-tile:clamp(20px,2.2vw,26px)}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--f-sans);font-size:17px;line-height:1.55;-webkit-font-smoothing:antialiased;overflow-x:hidden}
|
||||
a{color:inherit;text-decoration:none}a:hover{color:var(--molten)}
|
||||
a:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
|
||||
.wrap{max-width:var(--max);margin:0 auto;padding-inline:var(--gutter)}
|
||||
.mono{font-family:var(--f-mono)}
|
||||
.eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash)}
|
||||
h1,h2{font-family:var(--f-display);margin:0;line-height:1.08;text-wrap:balance}
|
||||
h1{font-weight:900;font-size:var(--fs-h1);letter-spacing:-.01em;overflow-wrap:anywhere}
|
||||
h2{font-weight:700;font-size:var(--fs-h3)}
|
||||
p{margin:0}
|
||||
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0;margin-top:var(--gap)}
|
||||
.head{padding-block:var(--head) var(--gap);display:flex;flex-direction:column;gap:14px}
|
||||
.head p{color:var(--ink-2);max-width:64ch;font-size:16px}
|
||||
.search{display:flex;gap:10px;max-width:760px;margin-top:4px}
|
||||
.search input{flex:1;min-width:0;min-height:48px;padding:10px 14px;border-radius:10px;border:1px solid var(--line-2);background:var(--graphite);color:var(--bone);font:500 15px/1.3 var(--f-mono)}
|
||||
.search input::placeholder{color:var(--ash)}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:48px;padding:12px 22px;border-radius:10px;font-weight:600;font-size:16px;border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;font-family:var(--f-sans)}
|
||||
.btn.primary{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)}
|
||||
.btn.primary:hover{background:#FF6A2B;border-color:#FF6A2B;color:var(--ember-ink)}
|
||||
.msg{font-family:var(--f-mono);font-size:13px;color:var(--molten);min-height:18px}
|
||||
.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)}
|
||||
@media (max-width:700px){.strip{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.cell .k{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.cell .v{font-family:var(--f-display);font-weight:700;font-size:var(--fs-tile);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cell .v small{font-family:var(--f-mono);font-weight:500;font-size:13px;color:var(--ash);margin-left:4px;letter-spacing:.04em}
|
||||
@media (max-width:700px){.cell .v{font-size:19px}}
|
||||
.cell .s{font-family:var(--f-mono);font-size:12px;color:var(--ash);text-wrap:balance}
|
||||
.cell.status .v{display:flex;align-items:center;gap:10px}
|
||||
.dot{width:10px;height:10px;border-radius:50%;background:var(--molten);display:inline-block;flex:0 0 10px;animation:pulse 2s ease-in-out infinite}
|
||||
.off .dot{background:var(--ash);animation:none}.off .cell.status .v{color:var(--ash)}.on .cell.status .v{color:var(--molten)}
|
||||
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 8px rgba(255,179,92,0)}}
|
||||
@media (prefers-reduced-motion:reduce){.dot{animation:none}}
|
||||
.viz-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px 20px;margin-bottom:12px}
|
||||
.tbl{overflow-x:auto;margin-top:12px;-webkit-overflow-scrolling:touch}
|
||||
table{border-collapse:collapse;width:100%;font-size:14px}
|
||||
th,td{padding:10px 10px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap;vertical-align:top}
|
||||
th{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500}
|
||||
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}
|
||||
tr:last-child td{border-bottom:0}
|
||||
tbody tr:hover td{background:rgba(255,255,255,.025)}
|
||||
.id{font-family:var(--f-mono);color:var(--molten)}
|
||||
.hash{font-family:var(--f-mono)}
|
||||
.sw{width:10px;height:10px;border-radius:3px;display:inline-block;margin-right:7px;vertical-align:middle;border:1.5px solid var(--ash)}
|
||||
.sw.chain{background:var(--ember);border-color:var(--blue)}.sw.blue{background:rgba(92,184,255,.7);border-color:var(--blue)}.sw.red{background:linear-gradient(45deg,transparent 44%,var(--ash) 44%,var(--ash) 56%,transparent 56%) center/8px 8px no-repeat}
|
||||
.chip{display:inline-block;font-family:var(--f-mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;padding:3px 8px;border-radius:999px;border:1px solid var(--line-2);color:var(--ink-2);margin-right:6px}
|
||||
.chip.hot{border-color:var(--molten);color:var(--molten)}.chip.ok{border-color:var(--green);color:var(--green)}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:6px 16px;margin-top:14px;font-size:13px;color:var(--ink-2)}
|
||||
.legend span{display:inline-flex;align-items:center;white-space:nowrap}
|
||||
.empty{color:var(--ash);font-size:14px;padding:12px 0}
|
||||
.note{font-size:13px;color:var(--ash);margin-top:14px;max-width:80ch}
|
||||
dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:8px 20px;margin:12px 0 0;font-size:14px}
|
||||
dt{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);padding-top:3px}
|
||||
dd{margin:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}
|
||||
@media (max-width:640px){dl{grid-template-columns:1fr;gap:2px 0}dd{margin-bottom:8px}}
|
||||
.two{display:grid;gap:var(--gap);grid-template-columns:1fr}
|
||||
@media (min-width:900px){.two{grid-template-columns:1fr 1fr}.two .card{margin-top:0}}
|
||||
.two-wrap{margin-top:var(--gap)}
|
||||
main{padding-bottom:var(--sec)}
|
||||
.foot{margin-top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- nav:start -->
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<nav class="nav" aria-label="Main">
|
||||
<div class="wrap">
|
||||
<a href="/" class="brand" aria-label="Igneum home">
|
||||
<svg class="mark" viewBox="0 0 1024 1024" width="36" height="36" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg>
|
||||
<span class="word">IGNEUM</span>
|
||||
</a>
|
||||
<button class="burger" id="nav-burger" type="button" aria-expanded="false" aria-controls="nav-links" aria-label="Menu"><svg class="bars" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"></path></svg><svg class="x" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"></path></svg></button>
|
||||
<div class="links" id="nav-links">
|
||||
<a href="/litepaper" data-nav="litepaper">Litepaper</a>
|
||||
<a href="/live" data-nav="live" aria-current="page">Live devnet</a>
|
||||
<a href="/bench" data-nav="bench">Engineering log</a>
|
||||
<a href="/miner" data-nav="miner">Miner</a>
|
||||
<a href="/wallet" data-nav="wallet">Wallet</a>
|
||||
<a href="/evidence" data-nav="evidence">Evidence</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener">GitHub</a>
|
||||
<a href="/miner" class="cta">The miner</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<script>
|
||||
(function(){
|
||||
// the menu: open on the button, closed by any link, a tap outside, Escape (focus back on the button) or a resize past 940 px
|
||||
var b=document.getElementById('nav-burger'),l=document.getElementById('nav-links');if(!b||!l)return;
|
||||
function set(o){l.classList.toggle('open',o);b.setAttribute('aria-expanded',o?'true':'false');}
|
||||
b.addEventListener('click',function(){set(!l.classList.contains('open'));});
|
||||
l.addEventListener('click',function(e){if(e.target.closest('a'))set(false);});
|
||||
document.addEventListener('click',function(e){if(l.classList.contains('open')&&!l.contains(e.target)&&!b.contains(e.target))set(false);});
|
||||
document.addEventListener('keydown',function(e){if(e.key==='Escape'&&l.classList.contains('open')){set(false);b.focus();}});
|
||||
var mq=window.matchMedia('(min-width:941px)');if(mq.addEventListener)mq.addEventListener('change',function(){set(false);});
|
||||
})();
|
||||
</script>
|
||||
<!-- nav:end -->
|
||||
<main id="main" class="wrap">
|
||||
<div class="head">
|
||||
<div class="eyebrow"><a href="/explorer">Explorer</a> · block</div>
|
||||
<h1 class="hash" id="title">Block</h1>
|
||||
<div id="chips"></div>
|
||||
<div class="msg" id="msg" aria-live="polite"></div>
|
||||
</div>
|
||||
<div id="body" hidden>
|
||||
<div class="two two-wrap">
|
||||
<div class="card"><div class="viz-head"><h2>Summary</h2></div><dl id="summary"></dl></div>
|
||||
<div class="card"><div class="viz-head"><h2>Header</h2></div><dl id="header"></dl></div>
|
||||
</div>
|
||||
<div class="two two-wrap">
|
||||
<div class="card"><div class="viz-head"><h2>Parents</h2><div class="eyebrow" id="parents-n"></div></div><div id="parents"></div><div class="viz-head" style="margin-top:18px"><h2>Children</h2><div class="eyebrow">seen within 10 min</div></div><div id="children"></div></div>
|
||||
<div class="card"><div class="viz-head"><h2>Mergeset</h2><div class="eyebrow">what this block merges</div></div><div id="mergeset"></div><p class="note">A chain block merges the blocks in its past that no earlier chain block merged. Blue ones are paid by this block's coinbase; red ones are excluded from the order and their reward goes to this block's miner.</p></div>
|
||||
</div>
|
||||
<div class="card"><div class="viz-head"><h2>Transactions</h2><div class="eyebrow" id="tx-n"></div></div><div id="coinbase"></div><div class="tbl" id="txs"></div></div>
|
||||
<div class="card"><div class="viz-head"><h2>Proof records and shards</h2><div class="eyebrow" id="proof-eyebrow"></div></div><div id="proofs"></div></div>
|
||||
<div class="card"><div class="viz-head"><h2>Finality</h2><div class="eyebrow" id="fin-eyebrow"></div></div><div id="finality"></div></div>
|
||||
</div>
|
||||
</main>
|
||||
<!-- footer:start -->
|
||||
<footer class="foot">
|
||||
<div class="wrap">
|
||||
<div class="foot-grid">
|
||||
<div class="foot-brand">
|
||||
<a href="/" class="brand" aria-label="Igneum home"><svg class="mark" viewBox="0 0 1024 1024" width="30" height="30" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg><span class="word">IGNEUM</span></a>
|
||||
<p>Mined by GPUs. Proven by fire.</p>
|
||||
</div>
|
||||
<nav class="foot-col" aria-label="Read">
|
||||
<div class="eyebrow">Read</div>
|
||||
<a href="/litepaper">Litepaper</a>
|
||||
<a href="/litepaper#limits">What Igneum does not claim</a>
|
||||
<a href="/litepaper#randomx">Igneum vs RandomX</a>
|
||||
<a href="/litepaper#shoulders">Built on the shoulders</a>
|
||||
<a href="/bench">Engineering log</a>
|
||||
<a href="/evidence">Evidence</a>
|
||||
</nav>
|
||||
<nav class="foot-col" aria-label="Run">
|
||||
<div class="eyebrow">Run</div>
|
||||
<a href="/miner">The miner</a>
|
||||
<a href="/wallet">The wallet</a>
|
||||
<a href="/miners">GPU bench table</a>
|
||||
<a href="/miner#fee">The dev fee</a>
|
||||
<a href="/faucet">Testnet faucet</a>
|
||||
</nav>
|
||||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
<a href="/miner#get">Miner downloads: public testnet</a>
|
||||
<a href="mailto:hello@igneum.network">Report a flaw: hello@igneum.network</a>
|
||||
<a href="https://github.com/igneum-network/spec/issues" rel="noopener">Or open an issue on the spec</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="foot-base">
|
||||
<span class="foot-imprint">Igneum Labs LTD · Unit IH-00-01-01-OF-01, Level 01, Innovation One, Dubai International Financial Centre · <a href="mailto:hello@igneum.network">hello@igneum.network</a></span>
|
||||
<span>© 2026 Igneum. Nothing on this page is an offer to sell anything.</span>
|
||||
<span class="mono">igneum.network</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- footer:end -->
|
||||
<script type="module">
|
||||
import { ign, ignFromWei, shortHash, rel, int, utc } from '/lib/explorer.mjs';
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const link = h => `<a class="hash" href="/block/${esc(h)}" title="${esc(h)}">${esc(shortHash(h, 12, 8))}</a>`;
|
||||
const addr = a => a ? `<a class="hash" href="/address/${esc(a)}">${esc(a)}</a>` : 'n/a';
|
||||
const dl = pairs => pairs.filter(p => p).map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join('');
|
||||
const id = decodeURIComponent(location.pathname.split('/').pop() || '');
|
||||
const query = /^\d{1,12}$/.test(id) ? 'height=' + id : 'block=' + encodeURIComponent(id.replace(/^0x/i, ''));
|
||||
async function load() {
|
||||
const r = await fetch('/api/explorer?' + query); const j = await r.json();
|
||||
if (!j.ok) { $('msg').textContent = j.error || 'Not found.'; $('title').textContent = /^\d/.test(id) ? 'Chain block ' + id : shortHash(id, 10, 8); return; }
|
||||
const b = j.block, d = b.detail || {}, now = new Date(j.now).getTime();
|
||||
document.title = `Igneum block ${shortHash(b.hash, 8, 6)}`;
|
||||
$('title').textContent = b.hash;
|
||||
const chips = [b.chain ? '<span class="chip hot">chain block</span>' : `<span class="chip">${esc(b.color)}</span>`];
|
||||
if (j.checkpoint) chips.push(`<span class="chip ${j.checkpoint.state === 'locked' ? 'ok' : ''}">checkpoint ${j.checkpoint.index}, ${esc(j.checkpoint.state)}</span>`);
|
||||
if (b.proof_records) chips.push(`<span class="chip">${b.proof_records} proof record${b.proof_records === 1 ? '' : 's'}</span>`);
|
||||
if (b.tx_count) chips.push(`<span class="chip">${b.tx_count} transaction${b.tx_count === 1 ? '' : 's'}</span>`);
|
||||
$('chips').innerHTML = chips.join('');
|
||||
$('summary').innerHTML = dl([
|
||||
['Number', b.number === null ? '<span style="color:var(--ash)">none, not a chain block</span>' : int(b.number)],
|
||||
['Time', `${utc(b.ts)} <span style="color:var(--ash)">(${rel(now - b.ts)})</span>`],
|
||||
['DAA score', int(b.daa)], ['Blue score', int(b.blue_score)],
|
||||
['Miner', addr(b.evm_miner)], ['Coinbase address', addr(b.miner)],
|
||||
['Vote key', `<span class="id">${esc(b.miner_id || 'n/a')}</span> <span class="hash" style="color:var(--ash);font-size:12px">${esc(b.vote_key_hash || '')}</span>`],
|
||||
b.engine ? ['Engine', esc(b.engine)] : null,
|
||||
['Difficulty', d.difficulty ? int(d.difficulty) : 'n/a'],
|
||||
['Declared subsidy', b.subsidy_sompi ? `${ign(b.subsidy_sompi, 8)} IGN <span style="color:var(--ash)">E(DAA) of spec 2.5, paid when a later chain block merges this one</span>` : 'n/a'],
|
||||
['Coinbase paid', b.paid_sompi ? `${ign(b.paid_sompi, 8)} IGN <span style="color:var(--ash)">to the blocks it merges</span>` : 'n/a'],
|
||||
['Merged by', b.chain ? 'itself (chain block)' : (j.merged_by ? link(j.merged_by) : '<span style="color:var(--ash)">not yet</span>')],
|
||||
['Selected parent', b.selected_parent ? link(b.selected_parent) : 'n/a'],
|
||||
]);
|
||||
$('header').innerHTML = dl([
|
||||
['Hash', `<span class="hash">${esc(b.hash)}</span>`], ['Version', esc(d.version)], ['Bits', esc(d.bits)], ['Nonce', `<span class="hash">${esc(d.nonce)}</span>`],
|
||||
['Blue work', `<span class="hash">${esc(d.blue_work)}</span>`], ['Hash merkle root', `<span class="hash">${esc(d.hash_merkle_root)}</span>`],
|
||||
['Accepted id merkle root', `<span class="hash">${esc(d.accepted_id_merkle_root)}</span>`], ['UTXO commitment', `<span class="hash">${esc(d.utxo_commitment)}</span>`],
|
||||
['Pruning point', `<span class="hash">${esc(d.pruning_point)}</span>`], ['Parent levels', esc(d.parents_by_level ?? 'n/a')],
|
||||
]);
|
||||
$('parents-n').textContent = `${b.parent_hashes.length} at level 0`;
|
||||
$('parents').innerHTML = b.parent_hashes.length ? '<div class="hash" style="display:flex;flex-direction:column;gap:6px">' + b.parent_hashes.map(link).join('') + '</div>' : '<div class="empty">Genesis has no parents.</div>';
|
||||
$('children').innerHTML = j.children.length ? '<div class="hash" style="display:flex;flex-direction:column;gap:6px">' + j.children.map(link).join('') + '</div>' : '<div class="empty">No child seen yet.</div>';
|
||||
const ms = d.mergeset || { blues: [], reds: [] };
|
||||
$('mergeset').innerHTML = (ms.blues.length || ms.reds.length) ? `<div style="display:flex;flex-direction:column;gap:6px">${ms.blues.map(h => `<div><i class="sw blue"></i>${link(h)}</div>`).join('')}${ms.reds.map(h => `<div><i class="sw red"></i>${link(h)}</div>`).join('')}</div>` : '<div class="empty">Empty.</div>';
|
||||
const cb = d.coinbase;
|
||||
$('tx-n').textContent = `${b.tx_count ?? 0} EVM, 1 coinbase`;
|
||||
$('coinbase').innerHTML = cb ? `<dl>${dl([['Coinbase', `<span class="hash">${esc(cb.id)}</span>`], ['Outputs', cb.outputs.map(o => `${o.pool ? '<span style="color:var(--ash)">proving pool (burns on the UTXO side, credited on the EVM side)</span>' : addr(o.address)} <b>${ign(o.value, 8)} IGN</b>`).join('<br>') || 'none'], ['Carries', `${cb.records} proof record${cb.records === 1 ? '' : 's'}${cb.reveal ? ', a key reveal' : ''}${(d.certificates || []).length ? `, certificate ${d.certificates.join(', ')}` : ''}`]])}</dl>` : '<div class="empty">No coinbase decoded.</div>';
|
||||
const txs = d.txs || []; const ev = j.evm && j.evm.transactions ? new Map(j.evm.transactions.map(t => [t.hash, t])) : null;
|
||||
$('txs').innerHTML = txs.length ? `<table><thead><tr><th>Transaction</th><th>From</th><th>To</th><th class="n">Value</th><th class="n">Bytes</th></tr></thead><tbody>${txs.map(t => { const e = ev && ev.get(t.hash); return `<tr id="tx-${esc((t.hash || '').replace(/^0x/, ''))}"><td class="hash">${esc(t.hash || '?')}</td><td>${e ? addr(e.from) : '<span style="color:var(--ash)">needs an EVM RPC</span>'}</td><td>${e ? addr(e.to) : ''}</td><td class="n">${e ? ignFromWei(e.value) + ' IGN' : ''}</td><td class="n">${t.size ?? ''}</td></tr>`; }).join('')}</tbody></table>` : '<div class="empty" style="margin-top:8px">No EVM transactions in this block.</div>';
|
||||
if (j.evm && !j.evm.error) $('txs').insertAdjacentHTML('beforeend', `<p class="note">EVM view of this chain block: gas used ${int(j.evm.gas_used)}, state root <span class="hash">${esc(shortHash(j.evm.state_root, 10, 6))}</span>${j.evm.igneum ? `, rewards ${j.evm.igneum.rewards.map(r => ignFromWei(r.wei) + ' IGN to ' + shortHash(r.miner, 6, 4)).join(', ')}, proving pool credit ${ignFromWei(j.evm.igneum.proving_pool_credit)} IGN` : ''}.</p>`);
|
||||
const sh = j.shards;
|
||||
$('proof-eyebrow').textContent = b.chain ? `${sh.length} shard${sh.length === 1 ? '' : 's'} planned for this chain block` : 'not a chain block';
|
||||
$('proofs').innerHTML = `<p style="font-size:14px;color:var(--ink-2)">This block carries <b>${b.proof_records ?? 0}</b> proof record${b.proof_records === 1 ? '' : 's'} for earlier chain blocks (274 bytes each, in the coinbase extra data).</p>` +
|
||||
(sh.length ? `<div class="tbl"><table><thead><tr><th class="n">Shard</th><th>State</th><th>Prover</th><th class="n">pgas</th><th class="n">Proof lag, DAA</th><th>Carried by</th><th class="n">Payout</th></tr></thead><tbody>${sh.map(s => `<tr><td class="n">${s.i} of ${s.n}</td><td><span class="chip ${s.state === 'paid' ? 'ok' : s.state === 'verified' ? 'hot' : ''}">${esc(s.state)}</span></td><td class="id">${esc(s.prover || '')}</td><td class="n">${int(s.pgas)}</td><td class="n">${s.lag === null ? '' : int(s.lag)}</td><td>${s.carried_by ? link(s.carried_by) : ''}</td><td class="n">${s.payout_wei ? ignFromWei(s.payout_wei, 6) + ' IGN' : ''}</td></tr>`).join('')}</tbody></table></div>` : (b.chain ? '<div class="empty">No shard plan recorded (the proving feed was not reading this block).</div>' : ''));
|
||||
const cp = j.checkpoint, certs = j.certificates || [];
|
||||
$('fin-eyebrow').textContent = cp ? `checkpoint ${cp.index}` : (certs.length ? 'carries a certificate' : 'not a checkpoint');
|
||||
$('finality').innerHTML = (cp ? `<dl>${dl([['State', `<span class="chip ${cp.state === 'locked' ? 'ok' : ''}">${esc(cp.state)}</span>`], ['Signed weight', `${int(cp.signed)} of ${int(cp.total)} (${(cp.fraction_total * 100).toFixed(1)}% of all weight, ${(cp.fraction_active * 100).toFixed(1)}% of active)`], ['Votes', `${int(cp.votes)} of ${int(cp.voters)} voters`], cp.locked_at ? ['Locked', utc(new Date(cp.locked_at).getTime())] : null])}</dl>` : '') +
|
||||
certs.map(c => `<dl style="margin-top:14px">${dl([[c.role === 'checkpoint' ? 'Certificate' : 'Carries certificate', `index ${c.index}, ${c.voter_count} voters, aggregator <span class="id">${esc(shortHash(c.aggregator, 8, 0))}</span>, ${c.role === 'checkpoint' ? 'carried by ' + link(c.carrier) : 'for checkpoint ' + link(c.checkpoint)}`], ['Weight', `${int(c.total_weight)} total, ${int(c.active_weight)} active at the lock`], c.previous ? ['Previous lock', `index ${c.previous.index}, ${link(c.previous.hash)}${c.headers_complete ? ', header chain stored' : ''}`] : null, ['Verify', `<a href="/api/checkpoint">/api/checkpoint</a> serves the newest certificate for the in-browser verifier`]])}</dl>`).join('') +
|
||||
(!cp && !certs.length ? '<div class="empty">This block is neither a checkpoint nor a certificate carrier. Checkpoints are every 30 blue score; a lock needs two thirds of all 30-day weight.</div>' : '');
|
||||
$('body').hidden = false;
|
||||
}
|
||||
load().catch(e => { $('msg').textContent = 'The API did not answer: ' + e.message; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -312,7 +312,9 @@ function stampDownloads(html, file, dl) {
|
|||
const downloads = await loadDownloads();
|
||||
built.push(`downloads (${downloads.source}: ${Object.keys(downloads.files || {}).join(', ') || 'none'})`);
|
||||
|
||||
const PAGES = [['index.html', ''], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['miner.html', 'miner'], ['wallet.html', 'wallet'], ['metamask.html', ''], ['faucet.html', ''], ['404.html', '']];
|
||||
const PAGES = [['index.html', ''], ['litepaper.html', 'litepaper'], ['live.html', 'live'], ['evidence.html', 'evidence'], ['miner.html', 'miner'], ['wallet.html', 'wallet'], ['metamask.html', ''], ['faucet.html', ''], ['404.html', ''],
|
||||
// the DAG explorer (5 Oct 2026): /explorer, /block/<hash> and /address/<addr> (vercel.json rewrites the last two)
|
||||
['explorer.html', 'live'], ['block.html', 'live'], ['address.html', 'live']];
|
||||
for (const [file, active] of PAGES) {
|
||||
const p = join(here, file);
|
||||
if (!existsSync(p)) throw new Error(`missing page ${file}`);
|
||||
|
|
|
|||
|
|
@ -3,27 +3,27 @@
|
|||
"files": {
|
||||
"miner-hive": {
|
||||
"alias": "/public/igneum-miner-hive.tar.gz",
|
||||
"file": "igneum-hive-0.3.8.tar.gz",
|
||||
"path": "/dl/public/igneum-hive-0.3.8.tar.gz",
|
||||
"sha256": "cc2fcbcaeab799364f41d613de52960337f703070c742d0df35961484002f927",
|
||||
"size": 24831127,
|
||||
"version": "0.3.8"
|
||||
"file": "igneum-hive-0.3.9.tar.gz",
|
||||
"path": "/dl/public/igneum-hive-0.3.9.tar.gz",
|
||||
"sha256": "7a58a30fd47c9ecb3d4aeaa0c0a464550f7e4b2b33eacf87512f1b72164c829e",
|
||||
"size": 24179978,
|
||||
"version": "0.3.9"
|
||||
},
|
||||
"miner-mac": {
|
||||
"alias": "/public/igneum-miner-mac.dmg",
|
||||
"file": "Igneum-Miner-0.3.9.dmg",
|
||||
"path": "/dl/public/Igneum-Miner-0.3.9.dmg",
|
||||
"sha256": "5e57c5735dc99e44475c43fba8ecffadae9dbd3a150c32ef4858587a98256de3",
|
||||
"size": 41333873,
|
||||
"version": "0.3.9"
|
||||
"file": "Igneum-Miner-0.3.10.dmg",
|
||||
"path": "/dl/public/Igneum-Miner-0.3.10.dmg",
|
||||
"sha256": "151687c5672553c571af7224a8e4228348135b8a313fc89e6641db7ae21c85b3",
|
||||
"size": 41383375,
|
||||
"version": "0.3.10"
|
||||
},
|
||||
"miner-windows": {
|
||||
"alias": "/public/igneum-miner-windows.exe",
|
||||
"file": "Igneum-Miner-Setup-0.3.9.exe",
|
||||
"path": "/dl/public/Igneum-Miner-Setup-0.3.9.exe",
|
||||
"sha256": "1d62a4dbdcef768ebd6fac5e46b199fea4de8854f4cf4c905eee49a6ed9334ce",
|
||||
"size": 19836912,
|
||||
"version": "0.3.9"
|
||||
"file": "Igneum-Miner-Setup-0.3.10.exe",
|
||||
"path": "/dl/public/Igneum-Miner-Setup-0.3.10.exe",
|
||||
"sha256": "24e58849f2b517e8c5579455e8c9d8376ff7dd27052f458ef91913cbc48abc88",
|
||||
"size": 49858273,
|
||||
"version": "0.3.10"
|
||||
},
|
||||
"wallet-mac": {
|
||||
"alias": "/public/igneum-wallet-mac.dmg",
|
||||
|
|
@ -34,5 +34,5 @@
|
|||
"version": "0.1.4"
|
||||
}
|
||||
},
|
||||
"updated": "2026-10-05T17:39:01Z"
|
||||
"updated": "2026-10-05T21:52:34Z"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -261,6 +261,7 @@ code{font-family:var(--f-mono);font-size:.92em;background:var(--obsidian);paddin
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
347
site/explorer.html
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Igneum explorer</title>
|
||||
<meta name="description" content="The Igneum block DAG, block by block: hash, DAA score, blue score, miner, transactions and proof records, with the network numbers and the supply.">
|
||||
<link rel="canonical" href="https://igneum.network/explorer">
|
||||
<meta name="theme-color" content="#0C0C0E">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Igneum">
|
||||
<meta property="og:title" content="Igneum explorer">
|
||||
<meta property="og:description" content="The Igneum block DAG, block by block: hash, DAA score, blue score, miner, transactions and proof records, with the network numbers and the supply.">
|
||||
<meta property="og:url" content="https://igneum.network/explorer">
|
||||
<meta property="og:image" content="https://igneum.network/og-small.png?v=3">
|
||||
<meta property="og:image:width" content="256">
|
||||
<meta property="og:image:height" content="256">
|
||||
<meta property="og:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Igneum explorer">
|
||||
<meta name="twitter:description" content="The Igneum block DAG, block by block, with the network numbers and the supply.">
|
||||
<meta name="twitter:image" content="https://igneum.network/og-small.png?v=3">
|
||||
<meta name="twitter:image:alt" content="Igneum. Mined by GPUs. Proven by fire.">
|
||||
<link rel="icon" href="/favicon.ico" sizes="48x48">
|
||||
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<!-- head:start -->
|
||||
<link rel="preload" href="/fonts/unbounded-900.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="preload" href="/fonts/plex-sans-400.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<style>
|
||||
/* Shared by every page. Source: site/partials/head.html, injected by site/build.mjs between the head markers. Edit the partial, not the page. */
|
||||
/* Fonts, self-hosted (latin subsets, OFL): Unbounded 500/700/900, IBM Plex Sans 400/500/600, IBM Plex Mono 400/500. Fallbacks carry size and ascent overrides so the swap does not move the layout. */
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/unbounded-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:700;font-display:swap;src:url(/fonts/unbounded-700.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded';font-style:normal;font-weight:900;font-display:swap;src:url(/fonts/unbounded-900.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-sans-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-sans-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Sans';font-style:normal;font-weight:600;font-display:swap;src:url(/fonts/plex-sans-600.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:400;font-display:swap;src:url(/fonts/plex-mono-400.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'IBM Plex Mono';font-style:normal;font-weight:500;font-display:swap;src:url(/fonts/plex-mono-500.woff2) format('woff2');unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
|
||||
@font-face{font-family:'Unbounded Fallback';src:local('Arial Black'),local('Arial-Black'),local('Impact');size-adjust:114%;ascent-override:87.5%;descent-override:21.5%;line-gap-override:0%}
|
||||
@font-face{font-family:'Plex Sans Fallback';src:local('Arial'),local('Helvetica Neue'),local('Helvetica');size-adjust:100.5%;ascent-override:102%;descent-override:27.4%;line-gap-override:0%}
|
||||
@font-face{font-family:'Plex Mono Fallback';src:local('Courier New'),local('Menlo');size-adjust:100%;ascent-override:102.5%;descent-override:27.5%;line-gap-override:0%}
|
||||
:root{--f-sans:'IBM Plex Sans','Plex Sans Fallback',system-ui,-apple-system,sans-serif;--f-display:'Unbounded','Unbounded Fallback',sans-serif;--f-mono:'IBM Plex Mono','Plex Mono Fallback',ui-monospace,Menlo,monospace;
|
||||
/* site chrome tokens (nav, footer, skip link). Dark by default; the litepaper sets light values and resets these in dark mode */
|
||||
--nav-h:68px;--ui-bg:rgba(12,12,14,.84);--ui-menu:#0C0C0E;--ui-ink:#F4F1EC;--ui-ink-2:#C9C7C2;--ui-ash:#9A9A9E;--ui-line:#2A2A30;--ui-line-2:#3A3A42;--ui-accent:#F2541B;--ui-accent-ink:#0C0C0E;--ui-hot:#FFB35C;--ui-hover:#FF6A2B;--ui-tint:rgba(242,84,27,.12)}
|
||||
html{-webkit-text-size-adjust:100%}
|
||||
h1,h2,h3{text-wrap:balance}
|
||||
p,li,dd,figcaption{text-wrap:pretty}
|
||||
a:focus-visible,button:focus-visible,summary:focus-visible,[tabindex]:focus-visible{outline:2px solid var(--ui-accent);outline-offset:3px;border-radius:6px}
|
||||
.skip{position:absolute;left:12px;top:-80px;z-index:50;background:var(--ui-accent);color:var(--ui-accent-ink);padding:10px 14px;border-radius:10px;font:600 15px/1.2 var(--f-sans);text-decoration:none}
|
||||
.skip:focus{top:12px}
|
||||
/* nav: one bar on every page, the mark in its black square, seven links and the miner button, a menu under 941 px (the eight items need 688 px beside the brand at 15 px, so the bar fits from 941 px up without a wrapped label) */
|
||||
.nav{position:sticky;top:0;z-index:20;background:var(--ui-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--ui-line);color:var(--ui-ink)}
|
||||
.nav .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:var(--nav-h)}
|
||||
.nav a{color:inherit;text-decoration:none}
|
||||
.brand{display:inline-flex;align-items:center;gap:10px;color:var(--ui-ink);text-decoration:none;flex:0 0 auto}
|
||||
.brand .mark{display:block;flex:0 0 auto}
|
||||
.brand .word{font-family:var(--f-display);font-weight:900;font-size:20px;letter-spacing:.06em;line-height:1}
|
||||
.nav .links{display:flex;align-items:center;gap:clamp(14px,2vw,26px);font-size:15px;font-weight:500}
|
||||
.nav .links a{color:var(--ui-ink-2);padding:4px 0;border-bottom:2px solid transparent;white-space:nowrap;transition:color .15s ease,border-color .15s ease}
|
||||
.nav .links a:hover,.nav .brand:hover{color:var(--ui-hot);text-decoration:none}
|
||||
.nav .brand:hover{color:var(--ui-ink)}
|
||||
.nav .links a[aria-current="page"]{color:var(--ui-ink);border-bottom-color:var(--ui-accent)}
|
||||
.nav .links a.cta{display:inline-flex;align-items:center;justify-content:center;min-height:40px;padding:8px 16px;border-radius:10px;font-weight:600;background:var(--ui-accent);color:var(--ui-accent-ink);border:1px solid var(--ui-accent);transition:background .15s ease,transform .15s ease}
|
||||
.nav .links a.cta:hover{background:var(--ui-hover);border-color:var(--ui-hover);color:var(--ui-accent-ink);transform:translateY(-1px)}
|
||||
.burger{display:none;background:none;border:1px solid var(--ui-line-2);color:var(--ui-ink);border-radius:10px;width:44px;height:44px;align-items:center;justify-content:center;cursor:pointer;padding:0;flex:0 0 auto}
|
||||
.burger .x{display:none}.burger[aria-expanded="true"] .x{display:block}.burger[aria-expanded="true"] .bars{display:none}
|
||||
@media (max-width:940px){
|
||||
.nav .links{display:none;position:absolute;left:0;right:0;top:100%;background:var(--ui-menu);border-bottom:1px solid var(--ui-line);box-shadow:0 24px 40px rgba(0,0,0,.35);padding:10px var(--gutter,16px) 18px;flex-direction:column;align-items:stretch;gap:2px}
|
||||
.nav .links a{padding:13px 10px;border-radius:10px;border-bottom:0;font-size:16px}
|
||||
.nav .links a[aria-current="page"]{background:var(--ui-tint)}
|
||||
.nav .links a.cta{margin-top:8px;min-height:48px}
|
||||
.nav .links.open{display:flex}
|
||||
.burger{display:inline-flex}
|
||||
}
|
||||
/* footer, the same on every page */
|
||||
.foot{border-top:1px solid var(--ui-line);color:var(--ui-ink);margin-top:var(--sec,64px)}
|
||||
.foot .wrap{padding-block:48px 32px}
|
||||
.foot-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,200px),1fr));gap:32px}
|
||||
.foot-brand{display:flex;flex-direction:column;gap:12px;max-width:34ch}
|
||||
.foot-brand .brand{align-self:flex-start}.foot-brand .word{font-size:16px}
|
||||
.foot-brand p{color:var(--ui-ash);font-size:14px;margin:0}
|
||||
.foot-col{display:flex;flex-direction:column;gap:10px;font-size:15px}
|
||||
.foot-col .eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ui-ash);margin-bottom:4px}
|
||||
.foot-col a{color:var(--ui-ink-2);text-decoration:none;display:inline-flex;align-items:center;gap:6px;align-self:flex-start}
|
||||
.foot-col a:hover{color:var(--ui-hot);text-decoration:none}
|
||||
.foot-base{display:flex;flex-wrap:wrap;justify-content:space-between;gap:8px 24px;margin-top:36px;padding-top:20px;border-top:1px solid var(--ui-line);font-size:13px;color:var(--ui-ash)}
|
||||
.foot-base span{text-wrap:balance}.foot-base .mono{font-family:var(--f-mono)}
|
||||
.foot-imprint{flex-basis:100%;text-wrap:pretty}.foot-base a{color:inherit;text-decoration:none}.foot-base a:hover{color:var(--ui-hot)}
|
||||
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation-duration:.01ms!important;animation-iteration-count:1!important;transition-duration:.01ms!important;scroll-behavior:auto!important}}
|
||||
</style>
|
||||
<!-- head:end -->
|
||||
<style>
|
||||
/* the explorer pages share this block with block.html and address.html; same tokens as live.html */
|
||||
:root{--obsidian:#0C0C0E;--graphite:#16161A;--line:#2A2A30;--line-2:#3A3A42;--ember:#F2541B;--molten:#FFB35C;--bone:#F4F1EC;--ash:#9A9A9E;--ink-2:#C9C7C2;--ember-ink:#0C0C0E;--blue:#5CB8FF;--green:#3FD39A;
|
||||
--max:1200px;--gutter:clamp(16px,4vw,32px);--sec:clamp(56px,8vw,96px);--head:clamp(40px,6vw,64px);
|
||||
--card-pad:clamp(18px,3vw,28px);--card-r:18px;--tile-pad:18px 20px;--tile-r:14px;--gap:24px;--gap-tile:12px;
|
||||
--fs-h1:clamp(32px,5.5vw,56px);--fs-h3:clamp(18px,2vw,22px);--fs-tile:clamp(20px,2.2vw,26px)}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--obsidian);color:var(--bone);font-family:var(--f-sans);font-size:17px;line-height:1.55;-webkit-font-smoothing:antialiased;overflow-x:hidden}
|
||||
a{color:inherit;text-decoration:none}a:hover{color:var(--molten)}
|
||||
a:focus-visible,button:focus-visible,input:focus-visible{outline:2px solid var(--ember);outline-offset:3px;border-radius:6px}
|
||||
.wrap{max-width:var(--max);margin:0 auto;padding-inline:var(--gutter)}
|
||||
.mono{font-family:var(--f-mono)}
|
||||
.eyebrow{font-family:var(--f-mono);font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--ash)}
|
||||
h1,h2{font-family:var(--f-display);margin:0;line-height:1.08;text-wrap:balance}
|
||||
h1{font-weight:900;font-size:var(--fs-h1);letter-spacing:-.01em;overflow-wrap:anywhere}
|
||||
h2{font-weight:700;font-size:var(--fs-h3)}
|
||||
p{margin:0}
|
||||
.card{background:var(--graphite);border:1px solid var(--line);border-radius:var(--card-r);padding:var(--card-pad);min-width:0;margin-top:var(--gap)}
|
||||
.head{padding-block:var(--head) var(--gap);display:flex;flex-direction:column;gap:14px}
|
||||
.head p{color:var(--ink-2);max-width:64ch;font-size:16px}
|
||||
.search{display:flex;gap:10px;max-width:760px;margin-top:4px}
|
||||
.search input{flex:1;min-width:0;min-height:48px;padding:10px 14px;border-radius:10px;border:1px solid var(--line-2);background:var(--graphite);color:var(--bone);font:500 15px/1.3 var(--f-mono)}
|
||||
.search input::placeholder{color:var(--ash)}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:48px;padding:12px 22px;border-radius:10px;font-weight:600;font-size:16px;border:1px solid var(--line-2);color:var(--bone);background:transparent;cursor:pointer;font-family:var(--f-sans)}
|
||||
.btn.primary{background:var(--ember);color:var(--ember-ink);border-color:var(--ember)}
|
||||
.btn.primary:hover{background:#FF6A2B;border-color:#FF6A2B;color:var(--ember-ink)}
|
||||
.msg{font-family:var(--f-mono);font-size:13px;color:var(--molten);min-height:18px}
|
||||
.strip{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:var(--gap-tile)}
|
||||
@media (max-width:700px){.strip{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
.cell{background:var(--graphite);border:1px solid var(--line);border-radius:var(--tile-r);padding:var(--tile-pad);display:flex;flex-direction:column;gap:6px;min-width:0}
|
||||
.cell .k{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash)}
|
||||
.cell .v{font-family:var(--f-display);font-weight:700;font-size:var(--fs-tile);line-height:1.1;font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cell .v small{font-family:var(--f-mono);font-weight:500;font-size:13px;color:var(--ash);margin-left:4px;letter-spacing:.04em}
|
||||
@media (max-width:700px){.cell .v{font-size:19px}}
|
||||
.cell .s{font-family:var(--f-mono);font-size:12px;color:var(--ash);text-wrap:balance}
|
||||
.cell.status .v{display:flex;align-items:center;gap:10px}
|
||||
.dot{width:10px;height:10px;border-radius:50%;background:var(--molten);display:inline-block;flex:0 0 10px;animation:pulse 2s ease-in-out infinite}
|
||||
.off .dot{background:var(--ash);animation:none}.off .cell.status .v{color:var(--ash)}.on .cell.status .v{color:var(--molten)}
|
||||
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(255,179,92,.5)}50%{box-shadow:0 0 0 8px rgba(255,179,92,0)}}
|
||||
@media (prefers-reduced-motion:reduce){.dot{animation:none}}
|
||||
.viz-head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:10px 20px;margin-bottom:12px}
|
||||
.tbl{overflow-x:auto;margin-top:12px;-webkit-overflow-scrolling:touch}
|
||||
table{border-collapse:collapse;width:100%;font-size:14px}
|
||||
th,td{padding:10px 10px;text-align:left;border-bottom:1px solid var(--line);white-space:nowrap;vertical-align:top}
|
||||
th{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);font-weight:500}
|
||||
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}
|
||||
tr:last-child td{border-bottom:0}
|
||||
tbody tr:hover td{background:rgba(255,255,255,.025)}
|
||||
.id{font-family:var(--f-mono);color:var(--molten)}
|
||||
.hash{font-family:var(--f-mono)}
|
||||
.sw{width:10px;height:10px;border-radius:3px;display:inline-block;margin-right:7px;vertical-align:middle;border:1.5px solid var(--ash)}
|
||||
.sw.chain{background:var(--ember);border-color:var(--blue)}.sw.blue{background:rgba(92,184,255,.7);border-color:var(--blue)}.sw.red{background:linear-gradient(45deg,transparent 44%,var(--ash) 44%,var(--ash) 56%,transparent 56%) center/8px 8px no-repeat}
|
||||
.chip{display:inline-block;font-family:var(--f-mono);font-size:11px;letter-spacing:.08em;text-transform:uppercase;padding:3px 8px;border-radius:999px;border:1px solid var(--line-2);color:var(--ink-2);margin-right:6px}
|
||||
.chip.hot{border-color:var(--molten);color:var(--molten)}.chip.ok{border-color:var(--green);color:var(--green)}
|
||||
.legend{display:flex;flex-wrap:wrap;gap:6px 16px;margin-top:14px;font-size:13px;color:var(--ink-2)}
|
||||
.legend span{display:inline-flex;align-items:center;white-space:nowrap}
|
||||
.empty{color:var(--ash);font-size:14px;padding:12px 0}
|
||||
.note{font-size:13px;color:var(--ash);margin-top:14px;max-width:80ch}
|
||||
dl{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:8px 20px;margin:12px 0 0;font-size:14px}
|
||||
dt{font-family:var(--f-mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--ash);padding-top:3px}
|
||||
dd{margin:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}
|
||||
@media (max-width:640px){dl{grid-template-columns:1fr;gap:2px 0}dd{margin-bottom:8px}}
|
||||
.two{display:grid;gap:var(--gap);grid-template-columns:1fr}
|
||||
@media (min-width:900px){.two{grid-template-columns:1fr 1fr}.two .card{margin-top:0}}
|
||||
.two-wrap{margin-top:var(--gap)}
|
||||
main{padding-bottom:var(--sec)}
|
||||
.foot{margin-top:0}
|
||||
</style>
|
||||
</head>
|
||||
<body class="off">
|
||||
<!-- nav:start -->
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<nav class="nav" aria-label="Main">
|
||||
<div class="wrap">
|
||||
<a href="/" class="brand" aria-label="Igneum home">
|
||||
<svg class="mark" viewBox="0 0 1024 1024" width="36" height="36" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg>
|
||||
<span class="word">IGNEUM</span>
|
||||
</a>
|
||||
<button class="burger" id="nav-burger" type="button" aria-expanded="false" aria-controls="nav-links" aria-label="Menu"><svg class="bars" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M4 7h16M4 12h16M4 17h16"></path></svg><svg class="x" viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"></path></svg></button>
|
||||
<div class="links" id="nav-links">
|
||||
<a href="/litepaper" data-nav="litepaper">Litepaper</a>
|
||||
<a href="/live" data-nav="live" aria-current="page">Live devnet</a>
|
||||
<a href="/bench" data-nav="bench">Engineering log</a>
|
||||
<a href="/miner" data-nav="miner">Miner</a>
|
||||
<a href="/wallet" data-nav="wallet">Wallet</a>
|
||||
<a href="/evidence" data-nav="evidence">Evidence</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener">GitHub</a>
|
||||
<a href="/miner" class="cta">The miner</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<script>
|
||||
(function(){
|
||||
// the menu: open on the button, closed by any link, a tap outside, Escape (focus back on the button) or a resize past 940 px
|
||||
var b=document.getElementById('nav-burger'),l=document.getElementById('nav-links');if(!b||!l)return;
|
||||
function set(o){l.classList.toggle('open',o);b.setAttribute('aria-expanded',o?'true':'false');}
|
||||
b.addEventListener('click',function(){set(!l.classList.contains('open'));});
|
||||
l.addEventListener('click',function(e){if(e.target.closest('a'))set(false);});
|
||||
document.addEventListener('click',function(e){if(l.classList.contains('open')&&!l.contains(e.target)&&!b.contains(e.target))set(false);});
|
||||
document.addEventListener('keydown',function(e){if(e.key==='Escape'&&l.classList.contains('open')){set(false);b.focus();}});
|
||||
var mq=window.matchMedia('(min-width:941px)');if(mq.addEventListener)mq.addEventListener('change',function(){set(false);});
|
||||
})();
|
||||
</script>
|
||||
<!-- nav:end -->
|
||||
<main id="main" class="wrap">
|
||||
<div class="head">
|
||||
<div class="eyebrow">devnet, last 24 hours</div>
|
||||
<h1>Explorer</h1>
|
||||
<p>Every block the devnet made in the last day, read from a node by the observer. Paste a block hash, a transaction hash, a chain block number or an address.</p>
|
||||
<form class="search" id="search" role="search">
|
||||
<input id="q" type="search" autocomplete="off" spellcheck="false" placeholder="block hash, transaction hash, chain block number, 0x address or igneumdev:… address" aria-label="Search the explorer">
|
||||
<button class="btn primary" type="submit">Search</button>
|
||||
</form>
|
||||
<div class="msg" id="msg" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="strip" aria-live="polite">
|
||||
<div class="cell status"><div class="k">Status</div><div class="v"><span class="dot"></span><span id="st-status">OFFLINE</span></div><div class="s" id="st-age">no observer update yet</div></div>
|
||||
<div class="cell"><div class="k">Height</div><div class="v" id="st-height">0</div><div class="s" id="st-count">chain block number, 0 blocks in the DAG</div></div>
|
||||
<div class="cell"><div class="k">DAA score</div><div class="v" id="st-daa">0</div><div class="s" id="st-blue">blue score 0</div></div>
|
||||
<div class="cell"><div class="k">Difficulty</div><div class="v" id="st-diff">0</div><div class="s" id="st-bt">target per block</div></div>
|
||||
<div class="cell"><div class="k">Hash rate</div><div class="v" id="st-hps">0</div><div class="s">network estimate</div></div>
|
||||
<div class="cell"><div class="k">Block reward</div><div class="v" id="st-reward">0<small>IGN</small></div><div class="s" id="st-split">80% miner, 20% proving pool</div></div>
|
||||
<div class="cell"><div class="k">Circulating</div><div class="v" id="st-supply">0<small>IGN</small></div><div class="s" id="st-cap">of 4,000,000,000 by the rule</div></div>
|
||||
<div class="cell"><div class="k">Next halving</div><div class="v" id="st-halving">0<small>d</small></div><div class="s" id="st-ramp">ramp</div></div>
|
||||
</div>
|
||||
<div class="card" aria-label="Latest blocks">
|
||||
<div class="viz-head"><h2>Latest blocks</h2><div class="eyebrow" id="blk-eyebrow">newest first, every 5 s</div></div>
|
||||
<div class="tbl">
|
||||
<table>
|
||||
<thead><tr><th>Block</th><th class="n">Number</th><th class="n">DAA</th><th class="n">Blue score</th><th>Miner</th><th class="n">Txs</th><th class="n">Proof records</th><th>Time</th></tr></thead>
|
||||
<tbody id="blocks"><tr><td colspan="8" class="empty">Loading.</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="legend"><span><i class="sw chain"></i>chain block</span><span><i class="sw blue"></i>blue, merged and paid</span><span><i class="sw red"></i>red, excluded</span><span><i class="sw"></i>pending, not merged yet</span><span>★ locked checkpoint</span></div>
|
||||
<p class="note">Number is the chain block number, the same number the EVM reports; a block off the selected chain has none. Miner is the payout address the coinbase names. Proof records is how many shard proofs the block carries for earlier blocks. Click a row for the block.</p>
|
||||
<button class="btn" id="more" type="button" style="margin-top:14px">Older blocks</button>
|
||||
</div>
|
||||
<div class="card" aria-label="Public API">
|
||||
<div class="viz-head"><h2>Public API</h2><div class="eyebrow">JSON, cached 10 s</div></div>
|
||||
<dl>
|
||||
<dt>/api/stats</dt><dd><a href="/api/stats" class="hash">network hash rate, difficulty, block time, block reward now, blocks per day, DAA, blue score, height, last block, chain id, version</a></dd>
|
||||
<dt>/api/supply</dt><dd><a href="/api/supply" class="hash">circulating by the emission rule, the 4,000,000,000 cap, the halving table, the 30-day ramp, the observer's coinbase check</a></dd>
|
||||
<dt>/api/explorer</dt><dd><a href="/api/explorer?blocks=20" class="hash">?blocks=N, ?block=hash, ?height=N, ?address=addr, ?search=q</a></dd>
|
||||
</dl>
|
||||
<p class="note">Reward and supply come from the emission rule of the specification (section 2.5) at the node's DAA score; the observer checks the chain's coinbase sums against it every hour and the result is in /api/supply under check.</p>
|
||||
</div>
|
||||
</main>
|
||||
<!-- footer:start -->
|
||||
<footer class="foot">
|
||||
<div class="wrap">
|
||||
<div class="foot-grid">
|
||||
<div class="foot-brand">
|
||||
<a href="/" class="brand" aria-label="Igneum home"><svg class="mark" viewBox="0 0 1024 1024" width="30" height="30" aria-hidden="true"><rect width="1024" height="1024" fill="#0C0C0E"></rect><g transform="translate(166.95 166.95) scale(6.901)"><polygon points="50,4 74,34 67,58 80,54 61,96 39,96 20,54 33,58 26,34" fill="#F2541B"></polygon><polygon points="50,42 59,58 50,82 41,58" fill="#0C0C0E"></polygon></g></svg><span class="word">IGNEUM</span></a>
|
||||
<p>Mined by GPUs. Proven by fire.</p>
|
||||
</div>
|
||||
<nav class="foot-col" aria-label="Read">
|
||||
<div class="eyebrow">Read</div>
|
||||
<a href="/litepaper">Litepaper</a>
|
||||
<a href="/litepaper#limits">What Igneum does not claim</a>
|
||||
<a href="/litepaper#randomx">Igneum vs RandomX</a>
|
||||
<a href="/litepaper#shoulders">Built on the shoulders</a>
|
||||
<a href="/bench">Engineering log</a>
|
||||
<a href="/evidence">Evidence</a>
|
||||
</nav>
|
||||
<nav class="foot-col" aria-label="Run">
|
||||
<div class="eyebrow">Run</div>
|
||||
<a href="/miner">The miner</a>
|
||||
<a href="/wallet">The wallet</a>
|
||||
<a href="/miners">GPU bench table</a>
|
||||
<a href="/miner#fee">The dev fee</a>
|
||||
<a href="/faucet">Testnet faucet</a>
|
||||
</nav>
|
||||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
<a href="/miner#get">Miner downloads: public testnet</a>
|
||||
<a href="mailto:hello@igneum.network">Report a flaw: hello@igneum.network</a>
|
||||
<a href="https://github.com/igneum-network/spec/issues" rel="noopener">Or open an issue on the spec</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="foot-base">
|
||||
<span class="foot-imprint">Igneum Labs LTD · Unit IH-00-01-01-OF-01, Level 01, Innovation One, Dubai International Financial Centre · <a href="mailto:hello@igneum.network">hello@igneum.network</a></span>
|
||||
<span>© 2026 Igneum. Nothing on this page is an offer to sell anything.</span>
|
||||
<span class="mono">igneum.network</span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<!-- footer:end -->
|
||||
<script type="module">
|
||||
import { classify, hrefFor, ign, shortHash, hashrate, rel, int } from '/lib/explorer.mjs';
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
let oldest = 0, serverNow = 0, serverAt = 0;
|
||||
const vnow = () => serverNow + (performance.now() - serverAt);
|
||||
$('search').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
const c = classify($('q').value);
|
||||
if (c.type === 'none') { $('msg').textContent = 'Not a block hash, a transaction hash, a chain block number or an address.'; return; }
|
||||
if (c.type === 'address') { location.href = hrefFor(c); return; }
|
||||
$('msg').textContent = 'Looking.';
|
||||
try {
|
||||
const r = await fetch('/api/explorer?search=' + encodeURIComponent($('q').value.trim()));
|
||||
const j = await r.json();
|
||||
if (j.ok && j.href) location.href = j.href; else $('msg').textContent = j.error || 'Not found.';
|
||||
} catch { $('msg').textContent = 'The API did not answer.'; }
|
||||
});
|
||||
function rowHtml(b) {
|
||||
const sw = b.chain ? 'chain' : b.color;
|
||||
const miner = b.evm_miner ? `<a class="hash" href="/address/${b.evm_miner}" title="${esc(b.evm_miner)}">${esc(shortHash(b.evm_miner, 6, 4))}</a>` : (b.miner ? `<a class="hash" href="/address/${esc(b.miner)}">${esc(shortHash(b.miner, 14, 4))}</a>` : '<span class="id">' + esc(b.miner_id || '?') + '</span>');
|
||||
return `<tr data-href="/block/${b.hash}"><td><i class="sw ${sw}" title="${b.chain ? 'chain block' : b.color}"></i><a class="hash" href="/block/${b.hash}">${esc(shortHash(b.hash, 10, 6))}</a>${b.locked ? ' ★' : ''}</td><td class="n">${b.number === null ? '<span style="color:var(--ash)">off chain</span>' : int(b.number)}</td><td class="n">${int(b.daa)}</td><td class="n">${int(b.blue_score)}</td><td>${miner}<span class="id" style="margin-left:8px;font-size:12px">${esc(b.miner_id || '')}</span></td><td class="n">${b.tx_count === null ? 'n/a' : b.tx_count}</td><td class="n">${b.proof_records === null ? 'n/a' : b.proof_records}</td><td title="${new Date(b.ts).toISOString()}">${rel(vnow() - b.ts)}</td></tr>`;
|
||||
}
|
||||
async function loadBlocks(before) {
|
||||
const r = await fetch('/api/explorer?blocks=50' + (before ? '&before=' + before : ''));
|
||||
const j = await r.json(); if (!j.ok) throw new Error(j.error);
|
||||
serverNow = new Date(j.now).getTime(); serverAt = performance.now();
|
||||
const html = j.blocks.map(rowHtml).join('');
|
||||
if (before) $('blocks').insertAdjacentHTML('beforeend', html); else $('blocks').innerHTML = html || '<tr><td colspan="8" class="empty">No blocks in the last 24 hours.</td></tr>';
|
||||
if (j.blocks.length) oldest = j.blocks[j.blocks.length - 1].rx;
|
||||
$('blk-eyebrow').textContent = (j.stale ? 'observer offline, last known' : 'newest first, every 5 s') + (j.network ? ', ' + j.network : '');
|
||||
}
|
||||
$('blocks').addEventListener('click', e => { const tr = e.target.closest('tr[data-href]'); if (tr && !e.target.closest('a')) location.href = tr.dataset.href; });
|
||||
$('more').addEventListener('click', () => { $('more').disabled = true; loadBlocks(oldest).catch(() => {}).finally(() => { $('more').disabled = false; clearInterval(timer); }); });
|
||||
async function loadStats() {
|
||||
const [s, p] = await Promise.all([fetch('/api/stats').then(r => r.json()), fetch('/api/supply').then(r => r.json())]);
|
||||
document.body.classList.toggle('on', !s.stale); document.body.classList.toggle('off', !!s.stale);
|
||||
$('st-status').textContent = s.stale ? 'OFFLINE' : 'LIVE';
|
||||
$('st-age').textContent = s.age_s === null ? 'no observer update yet' : (s.stale ? `last update ${rel(s.age_s * 1000)}` : `${s.network || ''}, node ${s.node_version || '?'}`);
|
||||
$('st-height').textContent = s.height === null ? 'n/a' : int(s.height);
|
||||
$('st-count').textContent = `chain block number, ${int(s.block_count)} blocks in the DAG`;
|
||||
$('st-daa').textContent = int(s.daa); $('st-blue').textContent = 'blue score ' + int(s.blue_score);
|
||||
$('st-diff').textContent = s.difficulty === null ? 'n/a' : (s.difficulty >= 1e6 ? (s.difficulty / 1e6).toFixed(2) + ' M' : int(s.difficulty));
|
||||
$('st-bt').textContent = s.block_time_measured_s === null ? 'target 1 s per block' : `${s.block_time_measured_s} s per block over 60 s, target 1 s`;
|
||||
$('st-hps').textContent = hashrate(s.hashrate);
|
||||
if (s.block_reward) { $('st-reward').innerHTML = esc(Number(s.block_reward.ign).toFixed(4)) + '<small>IGN</small>'; $('st-split').textContent = `${Number(s.block_reward.miner_ign).toFixed(4)} miner, ${Number(s.block_reward.proving_pool_ign).toFixed(4)} proving pool`; }
|
||||
if (p.ok) {
|
||||
$('st-supply').innerHTML = esc(int(Number(p.circulating_ign))) + '<small>IGN</small>';
|
||||
$('st-cap').textContent = `of ${int(Number(p.max_supply_ign))}, ${(Number(p.circulating_ign) / Number(p.max_supply_ign) * 100).toFixed(3)}% by the rule`;
|
||||
const d = p.halving.next_halving_in_s / 86400;
|
||||
$('st-halving').innerHTML = esc(d >= 1 ? Math.floor(d).toLocaleString('en-GB') : (p.halving.next_halving_in_s / 3600).toFixed(1)) + '<small>' + (d >= 1 ? 'days' : 'h') + '</small>';
|
||||
$('st-ramp').textContent = p.ramp.complete ? 'launch ramp complete' : `launch ramp at ${(p.ramp.factor_now * 100).toFixed(1)}%, full at DAA ${int(p.ramp.length_daa_s)}`;
|
||||
}
|
||||
}
|
||||
const timer = setInterval(() => { loadBlocks().catch(() => {}); }, 5000);
|
||||
setInterval(() => { loadStats().catch(() => {}); }, 10000);
|
||||
loadBlocks().catch(e => { $('blocks').innerHTML = `<tr><td colspan="8" class="empty">${esc(e.message)}</td></tr>`; });
|
||||
loadStats().catch(() => {});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -227,6 +227,7 @@ dt{color:var(--ash)}dd{margin:0;font-family:var(--f-mono);font-size:14px;overflo
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,16 @@
|
|||
}
|
||||
],
|
||||
"log": [
|
||||
{
|
||||
"date": "2026-10-05",
|
||||
"text": "The C4 fix: certificate-driven reorg",
|
||||
"short": "The C4 fix: certificate-driven reorg"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-05",
|
||||
"text": "EVM transaction relay: three nodes in a chain, every transaction sent to one end included by the other two miners",
|
||||
"short": "EVM transaction relay"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-05",
|
||||
"text": "The prover carries both fee tables and the height switch: one pinned guest on either side of DAA 210,000",
|
||||
|
|
@ -237,18 +247,8 @@
|
|||
},
|
||||
{
|
||||
"date": "2026-10-03",
|
||||
"text": "RTX 5090 first run",
|
||||
"short": "RTX 5090 first run"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-03",
|
||||
"text": "AMD gfx1036 , AMD OpenCL 2.1 driver 3652.0",
|
||||
"short": "AMD integrated GPU runs the hash through OpenCL"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-03",
|
||||
"text": "Igneum-node devnet v0: 3-node igneum-devnet at 1 BPS with the 80/20 coinbase and vote_key_hash",
|
||||
"short": "Devnet v0: three nodes at one block per second"
|
||||
"text": "RTX 5090, memory-hard dataset",
|
||||
"short": "RTX 5090 on the memory-hard dataset"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
122
site/lib/emission.mjs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// Igneum emission, spec 2.5 (docs/spec/02-consensus.md) as the node computes it
|
||||
// (vendor/igneum-node/consensus/core/src/igneum.rs: block_subsidy, launch_ramp). Shared by the observer's coinbase
|
||||
// check (tools/observer/observer.mjs) and the public /api/supply. BigInt throughout; sompi is the 8-decimal unit the
|
||||
// devnet pays in (open item O-2.6 keeps Kaspa's SOMPI_PER_KASPA), the EVM side shows the same amount at 18 decimals.
|
||||
|
||||
export const SOMPI_PER_IGN = 100_000_000n;
|
||||
export const SECONDS_PER_YEAR = 31_557_600n; // 365.25 days
|
||||
export const HALVING_INTERVAL_SECONDS = 2n * SECONDS_PER_YEAR; // 63,115,200
|
||||
export const YEAR_ONE_EMISSION_SOMPI = 1_000_000_000n * SOMPI_PER_IGN;
|
||||
export const BASE_SUBSIDY_PER_SECOND_SOMPI = YEAR_ONE_EMISSION_SOMPI / SECONDS_PER_YEAR; // 3,168,808,781 (floor)
|
||||
export const SUPPLY_CAP_SOMPI = 4_000_000_000n * SOMPI_PER_IGN;
|
||||
export const LAUNCH_RAMP_SECONDS = 2_592_000n; // 30 days
|
||||
export const LAUNCH_RAMP_START_PERCENT = 10n;
|
||||
export const PROVING_POOL_SHARE_PERCENT = 20n;
|
||||
export const SUBSIDY_PERIODS = 33; // period 32 pays 0
|
||||
|
||||
/** Per-second subsidy of two-year period i: BASE >> i (igneum.rs SUBSIDY_PER_SECOND_BY_PERIOD). */
|
||||
export function subsidyPerSecond(period) {
|
||||
const i = Math.min(Math.max(0, period), SUBSIDY_PERIODS - 1);
|
||||
return BASE_SUBSIDY_PER_SECOND_SOMPI >> BigInt(i);
|
||||
}
|
||||
|
||||
/** igneum.rs launch_ramp: linear from 10% at second 0 to 100% at LAUNCH_RAMP_SECONDS, integer floor. */
|
||||
export function launchRamp(fullSubsidy, secondsSinceGenesis) {
|
||||
const s = BigInt(secondsSinceGenesis);
|
||||
if (s >= LAUNCH_RAMP_SECONDS) return fullSubsidy;
|
||||
const numerator = LAUNCH_RAMP_START_PERCENT * LAUNCH_RAMP_SECONDS + (100n - LAUNCH_RAMP_START_PERCENT) * s;
|
||||
const denominator = 100n * LAUNCH_RAMP_SECONDS;
|
||||
return fullSubsidy * numerator / denominator;
|
||||
}
|
||||
|
||||
/** igneum.rs block_subsidy(daa_score, bps): per-block subsidy in sompi paid for each blue block merged at this DAA score. */
|
||||
export function blockSubsidy(daaScore, bps = 1) {
|
||||
const b = BigInt(bps);
|
||||
const seconds = BigInt(daaScore) / b;
|
||||
const period = Number(seconds / HALVING_INTERVAL_SECONDS);
|
||||
const perBlock = subsidyPerSecond(period) / b;
|
||||
return launchRamp(perBlock, seconds);
|
||||
}
|
||||
|
||||
/** The ramp factor at a DAA score as a decimal in [0.1, 1], for display. */
|
||||
export function rampFactor(daaScore) {
|
||||
const s = BigInt(daaScore);
|
||||
if (s >= LAUNCH_RAMP_SECONDS) return 1;
|
||||
return Number(LAUNCH_RAMP_START_PERCENT * LAUNCH_RAMP_SECONDS + (100n - LAUNCH_RAMP_START_PERCENT) * s) / Number(100n * LAUNCH_RAMP_SECONDS);
|
||||
}
|
||||
|
||||
// sum_{i=0}^{n-1} floor((a*i + b) / m), exact in O(log) (the classic floor-sum; a, b >= 0, m > 0, BigInt).
|
||||
export function floorSum(n, m, a, b) {
|
||||
let ans = 0n;
|
||||
for (;;) {
|
||||
if (a >= m) { ans += (n - 1n) * n / 2n * (a / m); a %= m; }
|
||||
if (b >= m) { ans += n * (b / m); b %= m; }
|
||||
const yMax = a * n + b;
|
||||
if (yMax < m) return ans;
|
||||
const n2 = yMax / m, b2 = yMax % m;
|
||||
[n, m, a, b] = [n2, a, m, b2];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coins minted by the rule from DAA 0 up to (not including) `daaScore`, in sompi, at `bps` = 1: the sum of
|
||||
* blockSubsidy(t) over every DAA second t. Exact: the ramp part is a floor-sum, the rest is per-period arithmetic.
|
||||
* This is the schedule; the chain pays blockSubsidy per blue block it merges (and per red inside the window), so
|
||||
* the paid total tracks it one block per DAA step. The observer's coinbase check (live_state.supply_check) reports
|
||||
* the difference it sees.
|
||||
*/
|
||||
export function mintedByRule(daaScore) {
|
||||
let t = BigInt(daaScore);
|
||||
if (t <= 0n) return 0n;
|
||||
let total = 0n;
|
||||
// ramp: per-second = floor(B * (10R + 90 s) / (100 R)) = floor((90B s + 10RB) / (100R))
|
||||
const B = BASE_SUBSIDY_PER_SECOND_SOMPI, R = LAUNCH_RAMP_SECONDS;
|
||||
const rampSeconds = t < R ? t : R;
|
||||
total += floorSum(rampSeconds, 100n * R, 90n * B, 10n * R * B);
|
||||
if (t <= R) return total;
|
||||
// after the ramp, period by period
|
||||
let from = R;
|
||||
while (from < t) {
|
||||
const period = Number(from / HALVING_INTERVAL_SECONDS);
|
||||
const periodEnd = BigInt(period + 1) * HALVING_INTERVAL_SECONDS;
|
||||
const to = t < periodEnd ? t : periodEnd;
|
||||
total += (to - from) * subsidyPerSecond(period);
|
||||
from = to;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Minted by the rule when every period has paid out: the cap minus the ramp's withheld part and the floors. */
|
||||
export function mintedAtEnd() {
|
||||
return mintedByRule(BigInt(SUBSIDY_PERIODS) * HALVING_INTERVAL_SECONDS);
|
||||
}
|
||||
|
||||
/** The halving table: one row per two-year period until the per-second subsidy is 0. */
|
||||
export function halvingTable(maxRows = SUBSIDY_PERIODS) {
|
||||
const rows = [];
|
||||
for (let i = 0; i < Math.min(maxRows, SUBSIDY_PERIODS); i++) {
|
||||
const start = BigInt(i) * HALVING_INTERVAL_SECONDS;
|
||||
rows.push({
|
||||
period: i,
|
||||
start_daa: start,
|
||||
end_daa: start + HALVING_INTERVAL_SECONDS,
|
||||
per_second_sompi: subsidyPerSecond(i),
|
||||
minted_by_end_sompi: mintedByRule(start + HALVING_INTERVAL_SECONDS),
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** sompi -> decimal IGN string with up to 8 places, no exponent, no rounding. */
|
||||
export function sompiToIgn(sompi, places = 8) {
|
||||
const v = BigInt(sompi); const neg = v < 0n; const a = neg ? -v : v;
|
||||
const whole = a / SOMPI_PER_IGN, frac = (a % SOMPI_PER_IGN).toString().padStart(8, '0').slice(0, places).replace(/0+$/, '');
|
||||
return (neg ? '-' : '') + whole.toString() + (frac ? '.' + frac : '');
|
||||
}
|
||||
|
||||
/** wei (18 decimals) -> decimal IGN string, up to `places` places. */
|
||||
export function weiToIgn(wei, places = 6) {
|
||||
const v = BigInt(wei); const neg = v < 0n; const a = neg ? -v : v; const U = 10n ** 18n;
|
||||
const whole = a / U, frac = (a % U).toString().padStart(18, '0').slice(0, places).replace(/0+$/, '');
|
||||
return (neg ? '-' : '') + whole.toString() + (frac ? '.' + frac : '');
|
||||
}
|
||||
56
site/lib/emission.test.mjs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as E from './emission.mjs';
|
||||
|
||||
test('constants match igneum.rs and spec 2.5', () => {
|
||||
assert.equal(E.BASE_SUBSIDY_PER_SECOND_SOMPI, 3_168_808_781n); // igneum.rs test: assert_eq!(BASE_SUBSIDY_PER_SECOND_SOMPI, 3_168_808_781)
|
||||
assert.equal(E.HALVING_INTERVAL_SECONDS, 63_115_200n);
|
||||
assert.equal(E.LAUNCH_RAMP_SECONDS, 2_592_000n);
|
||||
assert.equal(E.SUPPLY_CAP_SOMPI, 400_000_000_000_000_000n);
|
||||
});
|
||||
test('blockSubsidy reproduces the node tests (igneum.rs)', () => {
|
||||
assert.equal(E.blockSubsidy(2_592_000, 1), 3_168_808_781n); // ramp over
|
||||
assert.equal(E.blockSubsidy(63_115_200 - 1, 1), 3_168_808_781n);
|
||||
assert.equal(E.blockSubsidy(63_115_200, 1), 1_584_404_390n);
|
||||
assert.equal(E.blockSubsidy(2 * 63_115_200, 1), 792_202_195n);
|
||||
assert.equal(E.blockSubsidy(31 * 63_115_200, 1), 1n);
|
||||
assert.equal(E.blockSubsidy(32 * 63_115_200, 1), 0n);
|
||||
assert.equal(E.blockSubsidy(2_592_000 * 10, 10), 316_880_878n);
|
||||
assert.equal(E.blockSubsidy(63_115_200 * 10, 10), 158_440_439n);
|
||||
assert.equal(E.launchRamp(1_000_000n, 1_296_000), 550_000n);
|
||||
assert.equal(E.launchRamp(1_000_000n, 2_591_999), 999_999n);
|
||||
});
|
||||
test('blockSubsidy matches a devnet coinbase payload (block 2622db76, DAA 125,064, 5 Oct 2026)', () => {
|
||||
assert.equal(E.blockSubsidy(125_064, 1), 454_486_399n); // payload bytes 8..16 = 0x1b16e97f
|
||||
assert.equal(E.blockSubsidy(125_063, 1), 454_485_299n); // what its merged parent declared and it paid: 363,588,240 + 90,897,059
|
||||
});
|
||||
test('mintedByRule equals the brute-force sum and respects the cap', () => {
|
||||
let s = 0n; for (let t = 0; t < 3000; t++) { if (t === 777) assert.equal(E.mintedByRule(777), s); s += E.blockSubsidy(t, 1); }
|
||||
assert.equal(E.mintedByRule(3000), s);
|
||||
assert.equal(E.mintedByRule(0), 0n);
|
||||
const end = E.mintedAtEnd();
|
||||
assert.ok(end < E.SUPPLY_CAP_SOMPI);
|
||||
assert.equal(E.sompiToIgn(end), '3963038988.86765648'); // the cap less the ramp's withheld part (about 37 million) and the floors
|
||||
// the ramp withholds 0.45 x 2,592,000 / 31,557,600 x 10^9 = 36,961,... IGN (spec 2.5 "about 37 million")
|
||||
const withheld = E.SUPPLY_CAP_SOMPI - end;
|
||||
assert.ok(withheld > 36_900_000n * E.SOMPI_PER_IGN && withheld < 37_000_000n * E.SOMPI_PER_IGN, withheld.toString());
|
||||
});
|
||||
test('floorSum against a loop', () => {
|
||||
for (const [n, m, a, b] of [[10n, 7n, 3n, 5n], [1000n, 13n, 17n, 0n], [5n, 1n, 0n, 0n], [333n, 1000n, 999n, 999n]]) {
|
||||
let s = 0n; for (let i = 0n; i < n; i++) s += (a * i + b) / m;
|
||||
assert.equal(E.floorSum(n, m, a, b), s);
|
||||
}
|
||||
});
|
||||
test('halvingTable has 33 rows and halves per second', () => {
|
||||
const t = E.halvingTable();
|
||||
assert.equal(t.length, 33);
|
||||
assert.equal(t[0].per_second_sompi, 3_168_808_781n);
|
||||
assert.equal(t[1].per_second_sompi, 1_584_404_390n);
|
||||
assert.equal(t[32].per_second_sompi, 0n);
|
||||
assert.equal(t[1].start_daa, 63_115_200n);
|
||||
});
|
||||
test('sompiToIgn and weiToIgn', () => {
|
||||
assert.equal(E.sompiToIgn(454_485_299n), '4.54485299');
|
||||
assert.equal(E.sompiToIgn(100_000_000n), '1');
|
||||
assert.equal(E.weiToIgn('0x3275f7e5ad621000'), '3.636084');
|
||||
});
|
||||
90
site/lib/explorer.mjs
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
// Igneum explorer: the search router and the formatters shared by /api/explorer and the pages (the pages inline the
|
||||
// same functions; site/lib/explorer.test.mjs checks both). Zero dependencies.
|
||||
|
||||
const HEX64 = /^(0x)?[0-9a-f]{64}$/i;
|
||||
const HEX40 = /^0x[0-9a-f]{40}$/i;
|
||||
const BECH = /^(igneum|igneumdev|igneumtest|igneumsim):[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{20,}$/i;
|
||||
const DIGITS = /^\d{1,12}$/;
|
||||
|
||||
/**
|
||||
* Classifies what someone typed in the search box.
|
||||
* 64 hex (0x optional) -> {type: 'hash'} a DAG block hash, or an EVM transaction hash; the API tries the block first
|
||||
* 0x + 40 hex -> {type: 'address'} an EVM payout address
|
||||
* igneum...:bech32 -> {type: 'address'} the coinbase's UTXO address
|
||||
* digits -> {type: 'height'} a chain block number (the EVM block number)
|
||||
* anything else -> {type: 'none'}
|
||||
* `value` is the normalised form: hashes without 0x and lower case, 0x addresses lower case, numbers as Number.
|
||||
*/
|
||||
export function classify(input) {
|
||||
const q = String(input || '').trim();
|
||||
if (!q) return { type: 'none', value: null };
|
||||
if (HEX64.test(q)) return { type: 'hash', value: q.replace(/^0x/i, '').toLowerCase() };
|
||||
if (HEX40.test(q)) return { type: 'address', value: q.toLowerCase(), kind: 'evm' };
|
||||
if (BECH.test(q)) return { type: 'address', value: q.toLowerCase(), kind: 'utxo' };
|
||||
if (DIGITS.test(q)) return { type: 'height', value: Number(q) };
|
||||
return { type: 'none', value: null };
|
||||
}
|
||||
|
||||
/** The page a classified search leads to. A hash goes to /block/<hash>; the API answers 404 with a hint when it is not a block. */
|
||||
export function hrefFor(c) {
|
||||
if (c.type === 'hash') return `/block/${c.value}`;
|
||||
if (c.type === 'address') return `/address/${c.value}`;
|
||||
if (c.type === 'height') return `/block/${c.value}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** sompi (8 decimals) as IGN text, trailing zeros trimmed, at most `places` places. */
|
||||
export function ign(sompi, places = 4) {
|
||||
if (sompi === null || sompi === undefined) return 'n/a';
|
||||
let v; try { v = BigInt(sompi); } catch { return 'n/a'; }
|
||||
const neg = v < 0n; if (neg) v = -v;
|
||||
const whole = v / 100000000n, frac = (v % 100000000n).toString().padStart(8, '0').slice(0, places).replace(/0+$/, '');
|
||||
return (neg ? '-' : '') + whole.toLocaleString('en-GB') + (frac ? '.' + frac : '');
|
||||
}
|
||||
|
||||
/** wei (18 decimals) as IGN text. */
|
||||
export function ignFromWei(wei, places = 4) {
|
||||
if (wei === null || wei === undefined) return 'n/a';
|
||||
let v; try { v = BigInt(wei); } catch { return 'n/a'; }
|
||||
const neg = v < 0n; if (neg) v = -v; const U = 10n ** 18n;
|
||||
const whole = v / U, frac = (v % U).toString().padStart(18, '0').slice(0, places).replace(/0+$/, '');
|
||||
return (neg ? '-' : '') + whole.toLocaleString('en-GB') + (frac ? '.' + frac : '');
|
||||
}
|
||||
|
||||
/** First and last characters of a hash or address: 8 and 6 by default. */
|
||||
export function shortHash(h, head = 8, tail = 6) {
|
||||
const s = String(h || ''); if (!s) return '';
|
||||
if (s.length <= head + tail + 1) return s;
|
||||
return s.slice(0, head) + '…' + s.slice(-tail);
|
||||
}
|
||||
|
||||
/** A hash rate in H/s with a unit: 27,759,288 -> "27.8 MH/s". */
|
||||
export function hashrate(n) {
|
||||
if (n === null || n === undefined || isNaN(n)) return 'n/a';
|
||||
const u = ['H/s', 'kH/s', 'MH/s', 'GH/s', 'TH/s', 'PH/s']; let i = 0; let v = Number(n);
|
||||
while (v >= 1000 && i < u.length - 1) { v /= 1000; i++; }
|
||||
return (i ? v.toFixed(1) : String(Math.round(v))) + ' ' + u[i];
|
||||
}
|
||||
|
||||
/** A relative time from a millisecond difference: "12 s ago", "3 min ago", "2 h 5 min ago", "4 d ago". */
|
||||
export function rel(ms) {
|
||||
if (ms === null || ms === undefined || isNaN(ms)) return 'n/a';
|
||||
const s = Math.max(0, Math.round(ms / 1000));
|
||||
if (s < 60) return `${s} s ago`;
|
||||
const m = Math.floor(s / 60); if (m < 60) return `${m} min ago`;
|
||||
const h = Math.floor(m / 60); if (h < 24) return `${h} h ${m % 60} min ago`;
|
||||
return `${Math.floor(h / 24)} d ago`;
|
||||
}
|
||||
|
||||
/** Thousands-separated integer, or n/a. */
|
||||
export function int(n) {
|
||||
if (n === null || n === undefined || isNaN(n)) return 'n/a';
|
||||
return Math.round(Number(n)).toLocaleString('en-GB');
|
||||
}
|
||||
|
||||
/** A millisecond timestamp as "2026-10-05 19:21:56 UTC". */
|
||||
export function utc(ms) {
|
||||
if (!ms) return 'n/a';
|
||||
const d = new Date(Number(ms)); if (isNaN(d)) return 'n/a';
|
||||
return d.toISOString().slice(0, 19).replace('T', ' ') + ' UTC';
|
||||
}
|
||||
58
site/lib/explorer.test.mjs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { classify, hrefFor, ign, ignFromWei, shortHash, hashrate, rel, int, utc } from './explorer.mjs';
|
||||
|
||||
const H = '2622db7698c6825b370bbffba03ea11bba250792040296971d2b790a954203ee';
|
||||
|
||||
test('classify: a block hash with or without 0x, any case', () => {
|
||||
assert.deepEqual(classify(H), { type: 'hash', value: H });
|
||||
assert.deepEqual(classify('0x' + H.toUpperCase()), { type: 'hash', value: H });
|
||||
assert.deepEqual(classify(' ' + H + '\n'), { type: 'hash', value: H });
|
||||
});
|
||||
test('classify: an EVM address and a bech32 address', () => {
|
||||
assert.deepEqual(classify('0x37B55C8531053a5cef7fe1f19ba67012719773f9'), { type: 'address', value: '0x37b55c8531053a5cef7fe1f19ba67012719773f9', kind: 'evm' });
|
||||
const b = 'igneumdev:qr4yfyf9mzn643fj8faksflmxur0wxgtmpg8y7fa6qkm8pcjh3ngzqedfx47e';
|
||||
assert.deepEqual(classify(b), { type: 'address', value: b, kind: 'utxo' });
|
||||
assert.equal(classify('igneum:qr4yfyf9mzn643fj8faksflmxur0wxgtmpg8y7fa6qkm8pcjh3ngzqedfx47e').kind, 'utxo');
|
||||
});
|
||||
test('classify: digits are a height, the rest is nothing', () => {
|
||||
assert.deepEqual(classify('81263'), { type: 'height', value: 81263 });
|
||||
assert.equal(classify('0x1234').type, 'none');
|
||||
assert.equal(classify('').type, 'none');
|
||||
assert.equal(classify('hello').type, 'none');
|
||||
assert.equal(classify('0x' + 'a'.repeat(63)).type, 'none');
|
||||
assert.equal(classify('1'.repeat(13)).type, 'none');
|
||||
});
|
||||
test('hrefFor routes hash, height and address', () => {
|
||||
assert.equal(hrefFor(classify(H)), '/block/' + H);
|
||||
assert.equal(hrefFor(classify('12')), '/block/12');
|
||||
assert.equal(hrefFor(classify('0x37b55c8531053a5cef7fe1f19ba67012719773f9')), '/address/0x37b55c8531053a5cef7fe1f19ba67012719773f9');
|
||||
assert.equal(hrefFor(classify('nope')), null);
|
||||
});
|
||||
test('ign formats sompi, trims zeros, keeps places', () => {
|
||||
assert.equal(ign('454485299'), '4.5448');
|
||||
assert.equal(ign('454485299', 8), '4.54485299');
|
||||
assert.equal(ign(100000000n), '1');
|
||||
assert.equal(ign('123456789012345678', 2), '1,234,567,890.12');
|
||||
assert.equal(ign(null), 'n/a');
|
||||
assert.equal(ign('x'), 'n/a');
|
||||
});
|
||||
test('ignFromWei formats wei', () => {
|
||||
assert.equal(ignFromWei('0x3275f7e5ad621000'), '3.636');
|
||||
assert.equal(ignFromWei('3636084840000000000', 9), '3.63608484');
|
||||
assert.equal(ignFromWei(0), '0');
|
||||
});
|
||||
test('shortHash, hashrate, rel, int, utc', () => {
|
||||
assert.equal(shortHash(H), '2622db76…4203ee');
|
||||
assert.equal(shortHash('abc'), 'abc');
|
||||
assert.equal(hashrate(27759288), '27.8 MH/s');
|
||||
assert.equal(hashrate(999), '999 H/s');
|
||||
assert.equal(hashrate(null), 'n/a');
|
||||
assert.equal(rel(12_400), '12 s ago');
|
||||
assert.equal(rel(3 * 60_000), '3 min ago');
|
||||
assert.equal(rel(125 * 60_000), '2 h 5 min ago');
|
||||
assert.equal(rel(49 * 3_600_000), '2 d ago');
|
||||
assert.equal(int(1234567.4), '1,234,567');
|
||||
assert.equal(utc(1791227608731), '2026-10-05 19:13:28 UTC');
|
||||
assert.equal(utc(null), 'n/a');
|
||||
});
|
||||
|
|
@ -771,6 +771,7 @@ body.all .pager{display:none}
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
|
|
@ -298,6 +298,7 @@ main{padding-bottom:var(--sec)}
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
|
|
@ -260,6 +260,7 @@ pre{margin:0 0 16px;padding:16px 18px;background:var(--graphite);border-radius:v
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
|
|
@ -458,16 +458,16 @@ pre b{color:var(--molten);font-weight:500}
|
|||
<p data-testnet-notice style="margin-top:10px;font-weight:600;color:var(--molten)">Public testnet: not yet open; the devnet build is here for people who want to look.</p>
|
||||
</div>
|
||||
<div class="cta reveal" style="align-items:center">
|
||||
<a data-dl="miner-windows" href="https://dl.igneum.network/public/igneum-miner-windows.exe" class="btn primary"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M3 5.5l7.5-1v7H3zM11.5 4.3L21 3v8.5h-9.5zM3 12.5h7.5v7L3 18.5zM11.5 12.5H21V21l-9.5-1.3z"/></svg>Windows <span data-dl-meta="miner-windows" style="font-weight:400;opacity:.85">v0.3.9 · 19.8 MB</span></a>
|
||||
<a data-dl="miner-mac" href="https://dl.igneum.network/public/igneum-miner-mac.dmg" class="btn primary"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M16.4 12.6c0-2.3 1.9-3.4 2-3.5-1.1-1.6-2.8-1.8-3.4-1.8-1.4-.1-2.8.8-3.5.8-.7 0-1.8-.8-3-.8-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.4 2.9 2.3 1.2 0 1.6-.7 3-.7s1.8.7 3 .7c1.3 0 2-1.1 2.8-2.3.9-1.3 1.2-2.6 1.3-2.6-.1 0-2.5-.9-2.5-3.7zM14.1 5.8c.6-.8 1.1-1.9.9-3-.9 0-2 .6-2.7 1.4-.6.7-1.1 1.8-1 2.9 1.1.1 2.1-.5 2.8-1.3z"/></svg>macOS <span data-dl-meta="miner-mac" style="font-weight:400;opacity:.85">v0.3.9 · 41.3 MB</span></a>
|
||||
<a data-dl="miner-hive" href="https://dl.igneum.network/public/igneum-miner-hive.tar.gz" class="btn"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M12 2c-2.4 0-4 1.9-4 4.6 0 1.2.2 2 0 2.8-.6 1.4-2.1 2.9-2.6 4.9-.3 1.1-.1 2 .3 2.6-.6.4-1.3 1-1.1 1.7.3 1 2.1 1.2 3.2 1.8.7.4 1.5.6 2.1.1.6.2 1.3.3 2.1.3s1.5-.1 2.1-.3c.6.5 1.4.3 2.1-.1 1.1-.6 2.9-.8 3.2-1.8.2-.7-.5-1.3-1.1-1.7.4-.6.6-1.5.3-2.6-.5-2-2-3.5-2.6-4.9-.2-.8 0-1.6 0-2.8C16 3.9 14.4 2 12 2zm-1.4 4.2c.5 0 .8.5.8 1.2s-.3 1.2-.8 1.2-.8-.5-.8-1.2.3-1.2.8-1.2zm2.8 0c.5 0 .8.5.8 1.2s-.3 1.2-.8 1.2-.8-.5-.8-1.2.3-1.2.8-1.2zM12 9.3c.9 0 1.9.5 1.9 1s-1 1.2-1.9 1.2-1.9-.7-1.9-1.2 1-1 1.9-1zm0 3.4c2.2 0 3.6 2.6 3.6 4.4 0 1.5-1.6 2.3-3.6 2.3s-3.6-.8-3.6-2.3c0-1.8 1.4-4.4 3.6-4.4z"/></svg>Linux <span data-dl-meta="miner-hive" style="font-weight:400;opacity:.85">v0.3.8 · 24.8 MB</span></a>
|
||||
<a data-dl="miner-hive" href="https://dl.igneum.network/public/igneum-miner-hive.tar.gz" class="btn"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><path d="M12 2.5l8.2 4.75v9.5L12 21.5l-8.2-4.75v-9.5z"/><path d="M12 7.5l4.3 2.5v5L12 17.5l-4.3-2.5v-5z"/></svg>HiveOS <span data-dl-meta="miner-hive" style="font-weight:400;opacity:.85">v0.3.8 · 24.8 MB</span></a>
|
||||
<a data-dl="miner-windows" href="https://dl.igneum.network/public/igneum-miner-windows.exe" class="btn primary"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M3 5.5l7.5-1v7H3zM11.5 4.3L21 3v8.5h-9.5zM3 12.5h7.5v7L3 18.5zM11.5 12.5H21V21l-9.5-1.3z"/></svg>Windows <span data-dl-meta="miner-windows" style="font-weight:400;opacity:.85">v0.3.10 · 49.9 MB</span></a>
|
||||
<a data-dl="miner-mac" href="https://dl.igneum.network/public/igneum-miner-mac.dmg" class="btn primary"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M16.4 12.6c0-2.3 1.9-3.4 2-3.5-1.1-1.6-2.8-1.8-3.4-1.8-1.4-.1-2.8.8-3.5.8-.7 0-1.8-.8-3-.8-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.4 2.9 2.3 1.2 0 1.6-.7 3-.7s1.8.7 3 .7c1.3 0 2-1.1 2.8-2.3.9-1.3 1.2-2.6 1.3-2.6-.1 0-2.5-.9-2.5-3.7zM14.1 5.8c.6-.8 1.1-1.9.9-3-.9 0-2 .6-2.7 1.4-.6.7-1.1 1.8-1 2.9 1.1.1 2.1-.5 2.8-1.3z"/></svg>macOS <span data-dl-meta="miner-mac" style="font-weight:400;opacity:.85">v0.3.10 · 41.4 MB</span></a>
|
||||
<a data-dl="miner-hive" href="https://dl.igneum.network/public/igneum-miner-hive.tar.gz" class="btn"><svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true"><path d="M12 2c-2.4 0-4 1.9-4 4.6 0 1.2.2 2 0 2.8-.6 1.4-2.1 2.9-2.6 4.9-.3 1.1-.1 2 .3 2.6-.6.4-1.3 1-1.1 1.7.3 1 2.1 1.2 3.2 1.8.7.4 1.5.6 2.1.1.6.2 1.3.3 2.1.3s1.5-.1 2.1-.3c.6.5 1.4.3 2.1-.1 1.1-.6 2.9-.8 3.2-1.8.2-.7-.5-1.3-1.1-1.7.4-.6.6-1.5.3-2.6-.5-2-2-3.5-2.6-4.9-.2-.8 0-1.6 0-2.8C16 3.9 14.4 2 12 2zm-1.4 4.2c.5 0 .8.5.8 1.2s-.3 1.2-.8 1.2-.8-.5-.8-1.2.3-1.2.8-1.2zm2.8 0c.5 0 .8.5.8 1.2s-.3 1.2-.8 1.2-.8-.5-.8-1.2.3-1.2.8-1.2zM12 9.3c.9 0 1.9.5 1.9 1s-1 1.2-1.9 1.2-1.9-.7-1.9-1.2 1-1 1.9-1zm0 3.4c2.2 0 3.6 2.6 3.6 4.4 0 1.5-1.6 2.3-3.6 2.3s-3.6-.8-3.6-2.3c0-1.8 1.4-4.4 3.6-4.4z"/></svg>Linux <span data-dl-meta="miner-hive" style="font-weight:400;opacity:.85">v0.3.9 · 24.2 MB</span></a>
|
||||
<a data-dl="miner-hive" href="https://dl.igneum.network/public/igneum-miner-hive.tar.gz" class="btn"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true"><path d="M12 2.5l8.2 4.75v9.5L12 21.5l-8.2-4.75v-9.5z"/><path d="M12 7.5l4.3 2.5v5L12 17.5l-4.3-2.5v-5z"/></svg>HiveOS <span data-dl-meta="miner-hive" style="font-weight:400;opacity:.85">v0.3.9 · 24.2 MB</span></a>
|
||||
</div>
|
||||
<div class="card reveal" id="hive" style="margin-top:28px;overflow-wrap:anywhere;word-break:break-word;min-width:0">
|
||||
<div class="eyebrow">HiveOS · Flight Sheet</div>
|
||||
<h3 style="margin:8px 0 10px">Install on a Hive rig</h3>
|
||||
<p style="font-size:15px;color:var(--ink-2)">Miner: <b>Custom</b>. Installation URL: <code data-dl-url="miner-hive">https://dl.igneum.network/dl/public/igneum-hive-0.3.8.tar.gz</code>. Miner name <code>igneum</code>, wallet and worker <code>0x<your 40-hex payout address>.%WORKER_NAME%</code>, pool URL <code>grpc://<your node>:26610</code> or <code>local</code> for the bundled node, extra config <code>DEV_FEE=1 IDENTITIES=8 WORKER=auto VOTE=1</code>. The Linux button above is the same archive: the Linux miner, node and both GPU workers. Hive itself is untested so far. Report what breaks.</p>
|
||||
<p style="font-size:13px;color:var(--ash);margin-top:8px;overflow-wrap:anywhere">sha256 <span class="mono" data-dl-sha="miner-hive" style="word-break:break-all">cc2fcbcaeab799364f41d613de52960337f703070c742d0df35961484002f927</span></p>
|
||||
<p style="font-size:15px;color:var(--ink-2)">Miner: <b>Custom</b>. Installation URL: <code data-dl-url="miner-hive">https://dl.igneum.network/dl/public/igneum-hive-0.3.9.tar.gz</code>. Miner name <code>igneum</code>, wallet and worker <code>0x<your 40-hex payout address>.%WORKER_NAME%</code>, pool URL <code>grpc://<your node>:26610</code> or <code>local</code> for the bundled node, extra config <code>DEV_FEE=1 IDENTITIES=8 WORKER=auto VOTE=1</code>. The Linux button above is the same archive: the Linux miner, node and both GPU workers. Hive itself is untested so far. Report what breaks.</p>
|
||||
<p style="font-size:13px;color:var(--ash);margin-top:8px;overflow-wrap:anywhere">sha256 <span class="mono" data-dl-sha="miner-hive" style="word-break:break-all">7a58a30fd47c9ecb3d4aeaa0c0a464550f7e4b2b33eacf87512f1b72164c829e</span></p>
|
||||
</div>
|
||||
<p class="pt reveal" style="margin-top:20px">Testnet coin for testing: <a href="/faucet" style="color:var(--ember)">the faucet</a> sends 10 IGN per address per day. Any 4 GB card. The dataset starts at 2 GB and grows by half a gigabyte a year, approximate. Updates arrive signed; the app installs them itself. The <a href="/wallet" style="color:var(--ember)">wallet</a> reads this machine's node when the miner is installed.</p>
|
||||
<a href="/litepaper#miners" class="lp reveal">Read more in the litepaper</a>
|
||||
|
|
@ -513,6 +513,7 @@ pre b{color:var(--molten);font-weight:500}
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
|
|
@ -217,6 +217,7 @@ th{font-family:var(--f-mono);font-size:12px;letter-spacing:.12em;text-transform:
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
|
|
@ -1,18 +1,17 @@
|
|||
{
|
||||
"cleanUrls": true,
|
||||
"trailingSlash": false,
|
||||
"redirects": [ { "source": "/ledger", "destination": "/", "permanent": false } ],
|
||||
"redirects": [{"source": "/ledger", "destination": "/", "permanent": false}],
|
||||
"rewrites": [{"source": "/block/:id", "destination": "/block"}, {"source": "/address/:addr", "destination": "/address"}],
|
||||
"headers": [
|
||||
{ "source": "/(.*)", "headers": [
|
||||
{ "key": "X-Content-Type-Options", "value": "nosniff" },
|
||||
{ "key": "X-Frame-Options", "value": "DENY" },
|
||||
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
|
||||
{ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" }
|
||||
] },
|
||||
{ "source": "/fonts/(.*)", "headers": [ { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } ] },
|
||||
{ "source": "/(.*)\\.(png|ico)", "headers": [ { "key": "Cache-Control", "value": "public, max-age=86400, stale-while-revalidate=604800" } ] },
|
||||
{ "source": "/site.webmanifest", "headers": [ { "key": "Cache-Control", "value": "public, max-age=86400, stale-while-revalidate=604800" } ] },
|
||||
{ "source": "/journey.json", "headers": [ { "key": "Cache-Control", "value": "public, max-age=300" } ] },
|
||||
{ "source": "/api/live", "headers": [ { "key": "Cache-Control", "value": "public, max-age=1" } ] }
|
||||
{"source": "/(.*)", "headers": [{"key": "X-Content-Type-Options", "value": "nosniff"}, {"key": "X-Frame-Options", "value": "DENY"}, {"key": "Referrer-Policy", "value": "strict-origin-when-cross-origin"}, {"key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload"}]},
|
||||
{"source": "/fonts/(.*)", "headers": [{"key": "Cache-Control", "value": "public, max-age=31536000, immutable"}]},
|
||||
{"source": "/(.*)\\.(png|ico)", "headers": [{"key": "Cache-Control", "value": "public, max-age=86400, stale-while-revalidate=604800"}]},
|
||||
{"source": "/site.webmanifest", "headers": [{"key": "Cache-Control", "value": "public, max-age=86400, stale-while-revalidate=604800"}]},
|
||||
{"source": "/journey.json", "headers": [{"key": "Cache-Control", "value": "public, max-age=300"}]},
|
||||
{"source": "/api/live", "headers": [{"key": "Cache-Control", "value": "public, max-age=1"}]},
|
||||
{"source": "/api/stats", "headers": [{"key": "Cache-Control", "value": "public, max-age=10, s-maxage=10"}]},
|
||||
{"source": "/api/supply", "headers": [{"key": "Cache-Control", "value": "public, max-age=10, s-maxage=10"}]},
|
||||
{"source": "/api/explorer", "headers": [{"key": "Cache-Control", "value": "public, max-age=5, s-maxage=5"}]}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -415,6 +415,7 @@ td.num{font-variant-numeric:tabular-nums;white-space:nowrap}
|
|||
<nav class="foot-col" aria-label="Follow">
|
||||
<div class="eyebrow">Follow</div>
|
||||
<a href="/live">Live devnet</a>
|
||||
<a href="/explorer">Explorer</a>
|
||||
<a href="/#journey">Journey</a>
|
||||
<a href="/wallet">Add Igneum to MetaMask</a>
|
||||
<a href="https://github.com/igneum-network/spec" rel="noopener"><svg viewBox="0 0 24 24" width="15" height="15" fill="currentColor" aria-hidden="true"><path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2.1c-3.2.7-3.9-1.4-3.9-1.4-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.7-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.5-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4s2 .1 2.9.4c2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.8.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.2c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"></path></svg>GitHub, spec and vectors</a>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// and puts them where the packaging scripts look.
|
||||
// node tools/build-job.mjs run [--node vendor/igneum-node-v4] [--target ae432dc7] [--budget-minutes 40]
|
||||
// [--stage-minutes '{"linux":20}'] [--targets linux,windows] [--no-tests]
|
||||
// [--node-tests "kaspa-consensus-core"] [--app-tests "igneum-app"] [--no-app]
|
||||
// [--node-tests "kaspa-consensus-core"] [--app-tests "igneum-app"] [--no-app] [--no-node]
|
||||
// [--title "..."] [--no-place] [--out dir] pack + publish + watch + fetch
|
||||
// node tools/build-job.mjs publish [the same flags] pack + publish, prints the id
|
||||
// node tools/build-job.mjs watch <job id> STAGE and RESULT lines as they land
|
||||
|
|
@ -32,7 +32,7 @@ const RELAY_BASE = (cfg('relay-url') || 'https://relay.igneum.network').replace(
|
|||
|
||||
const argv = process.argv.slice(2);
|
||||
const flags = {}; const pos = [];
|
||||
const BOOL = new Set(['no-tests', 'no-place', 'no-deploy', 'no-app', 'help']);
|
||||
const BOOL = new Set(['no-tests', 'no-place', 'no-deploy', 'no-app', 'no-node', 'help']);
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a.startsWith('--')) { const k = a.slice(2); const next = argv[i + 1]; if (!BOOL.has(k) && next !== undefined && !next.startsWith('--')) { flags[k] = next; i++; } else flags[k] = true; }
|
||||
|
|
@ -208,11 +208,16 @@ function placeFor(o) {
|
|||
|
||||
// ---- publish: pack, then the signed job ---------------------------------------------------------------------------------------
|
||||
function publish() {
|
||||
const pack = ['packaging/windows/push-build-inputs.sh'];
|
||||
// One zip per job (5 October 2026 night): with the one shared build-inputs.zip, a job published while another agent's
|
||||
// pack landed in the downloads folder pinned THAT agent's sources (three C4 jobs built tx-gossip and m20-live). The
|
||||
// name carries the time and this process id; push-build-inputs.sh prunes names older than two days.
|
||||
const zipName = `build-inputs-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)}-${process.pid}.zip`;
|
||||
const pack = ['packaging/windows/push-build-inputs.sh', '--name', zipName];
|
||||
if (flags.node) pack.push('--node', flags.node);
|
||||
if (flags['node-tests']) pack.push('--node-tests', String(flags['node-tests']));
|
||||
if (flags['app-tests']) pack.push('--app-tests', String(flags['app-tests']));
|
||||
if (flags['no-app']) pack.push('--no-app');
|
||||
if (flags['no-node']) pack.push('--no-node');
|
||||
if (flags['no-deploy']) pack.push('--no-deploy');
|
||||
console.log(`$ ${pack.join(' ')}`);
|
||||
const r = spawnSync('bash', pack, { cwd: ROOT, stdio: 'inherit' });
|
||||
|
|
@ -226,6 +231,9 @@ function publish() {
|
|||
if (flags.title) add.push('--title', String(flags.title));
|
||||
if (flags.id) add.push('--id', String(flags.id));
|
||||
if (!flags['no-deploy']) add.push('--deploy');
|
||||
const dlsite = (process.env.IGNEUM_DLSITE || cfg('dlsite-dir')).trim(), dlToken = cfg('dl-token');
|
||||
if (dlsite && dlToken) add.push('--zip', join(dlsite, 'dl', dlToken, zipName));
|
||||
else { console.error('no ~/.config/igneum/dlsite-dir or dl-token: cannot name the job\'s zip'); process.exit(1); }
|
||||
console.log(`$ ${add.join(' ')}`);
|
||||
const a = spawnSync('bash', add, { cwd: ROOT, encoding: 'utf8' });
|
||||
process.stdout.write(a.stdout || ''); process.stderr.write(a.stderr || '');
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Internal link check of the site: every href="..." and src="..." in site/*.html that is not an external URL,
|
||||
// an anchor, a mailto or a data URI must resolve to a file under site/ (clean URLs: /bench -> bench.html).
|
||||
// Fragment links (#id) inside a page must name an element id in that page.
|
||||
// Fragment links (#id) inside a page must name an element id in that page. Template literals (${...}) in scripts are
|
||||
// skipped; /api/<name> resolves to the function file site/api/<name>.mjs.
|
||||
// node tools/ci/link-check.mjs exit 1 on the first broken link, listing every one
|
||||
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
|
|
@ -15,6 +16,9 @@ function resolves(target) {
|
|||
const clean = target.replace(/[?#].*$/, '');
|
||||
if (clean === '' || clean === '/') return true;
|
||||
const rel = clean.replace(/^\//, '');
|
||||
// /api/<name> is a function: site/api/<name>.mjs (Vercel routes the path to it)
|
||||
const api = /^api\/([a-z0-9_-]+)$/.exec(rel);
|
||||
if (api) return existsSync(join(site, 'api', `${api[1]}.mjs`));
|
||||
const candidates = [join(site, rel), join(site, `${rel}.html`), join(site, rel, 'index.html')];
|
||||
return candidates.some(c => existsSync(c) && statSync(c).isFile());
|
||||
}
|
||||
|
|
@ -25,6 +29,8 @@ for (const page of pages) {
|
|||
for (const m of html.matchAll(/\s(?:href|src)="([^"]*)"/g)) {
|
||||
const t = m[1];
|
||||
if (/^(https?:|mailto:|data:|tel:|javascript:)/i.test(t) || t.startsWith('//')) continue;
|
||||
// a template literal inside a page's script builds the href at run time (the explorer pages: /block/${hash})
|
||||
if (t.includes('${')) continue;
|
||||
checked++;
|
||||
if (t.startsWith('#')) { if (t.length > 1 && !ids.has(t.slice(1))) broken.push(`${page}: fragment ${t}`); continue; }
|
||||
const [path, frag] = t.split('#');
|
||||
|
|
|
|||
27
tools/ci/public-api-check.mjs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#!/usr/bin/env node
|
||||
// Checks that a deployed site answers /api/stats and /api/supply with every documented field (the FIELDS lists in
|
||||
// site/api/stats.mjs and site/api/supply.mjs, the same lists docs/api/public-stats.md is written from).
|
||||
// node tools/ci/public-api-check.mjs [https://igneum.network]
|
||||
// Exit 1 on a missing field, a non-200 answer or a stale observer older than 10 minutes (the numbers are what pool
|
||||
// software and profitability sites read; a stale answer is a wrong answer).
|
||||
import { FIELDS as STATS, REWARD_FIELDS, LAST_BLOCK_FIELDS } from '../../site/api/stats.mjs';
|
||||
import { FIELDS as SUPPLY, SCHEDULE_FIELDS } from '../../site/api/supply.mjs';
|
||||
|
||||
const base = (process.argv[2] || process.env.PUBLIC_API_URL || 'https://igneum.network').replace(/\/$/, '');
|
||||
const missing = [];
|
||||
async function get(path) {
|
||||
const r = await fetch(base + path, { headers: { 'cache-control': 'no-cache' }, signal: AbortSignal.timeout(15000) });
|
||||
const j = await r.json().catch(() => null);
|
||||
if (r.status !== 200 || !j || j.ok !== true) { console.error(`${path}: HTTP ${r.status} ${j ? JSON.stringify(j).slice(0, 200) : ''}`); process.exit(1); }
|
||||
return j;
|
||||
}
|
||||
const s = await get('/api/stats');
|
||||
for (const f of STATS) if (!(f in s)) missing.push(`stats.${f}`);
|
||||
if (s.block_reward) for (const f of REWARD_FIELDS) if (!(f in s.block_reward)) missing.push(`stats.block_reward.${f}`);
|
||||
if (s.last_block) for (const f of LAST_BLOCK_FIELDS) if (!(f in s.last_block)) missing.push(`stats.last_block.${f}`);
|
||||
const p = await get('/api/supply');
|
||||
for (const f of SUPPLY) if (!(f in p)) missing.push(`supply.${f}`);
|
||||
if (p.schedule && p.schedule[0]) for (const f of SCHEDULE_FIELDS) if (!(f in p.schedule[0])) missing.push(`supply.schedule.${f}`);
|
||||
if (missing.length) { console.error(`public api check: ${missing.length} missing field(s): ${missing.join(', ')}`); process.exit(1); }
|
||||
if (s.stale && s.age_s > 600) { console.error(`public api check: the observer is stale (${s.age_s} s)`); process.exit(1); }
|
||||
console.log(`public api check: ${base} /api/stats (${STATS.length} fields, height ${s.height}, reward ${s.block_reward && s.block_reward.ign} IGN, ${s.stale ? 'STALE ' + s.age_s + ' s' : 'live'}) and /api/supply (${SUPPLY.length} fields, circulating ${p.circulating_ign} IGN of ${p.max_supply_ign}) answer with every documented field`);
|
||||