Proving v0: payouts in the shard statement (fixture, ShardInput, executor), the empty-segment plan fix, host modes compressed and verify, exporter reads payouts; the app's prover service (src/prover.rs); the 3-node test network script (tools/proving-v0/run.mjs); proving_v0_activation_daa in the fast-time profile
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
2548e98460
commit
ed1cbb02eb
11 changed files with 798 additions and 17 deletions
458
app/igneum-app/src/prover.rs
Normal file
458
app/igneum-app/src/prover.rs
Normal file
|
|
@ -0,0 +1,458 @@
|
|||
//! The prover service (proving v0, docs/spec/07-execution.md 7.7, docs/plans/proving-v0.md): when `prove` is on
|
||||
//! in the settings, this machine proves the shards the chain assigns to its vote keys and submits the proof
|
||||
//! records to its own node, which relays them and carries them in blocks; the carrying segment pays the shard's
|
||||
//! part of the proving pool to the rewards address.
|
||||
//!
|
||||
//! One thread, its own loop, no job mechanism of its own: every 10 s while enabled it asks the node for the
|
||||
//! shards assigned to this machine's keys (`igneum_getAssignedShards`), takes the newest one not yet paid or
|
||||
//! attempted, exports the chain (`igneum_exportSegments`) and cuts the fixture (`igneum-prove-export`), proves
|
||||
//! the shard (`igneum-prove-host --mode compressed`), signs the record with the identity's vote key
|
||||
//! (`igneum-miner sign-record`, the same label the miner derives the key from) and submits it
|
||||
//! (`igneum_submitProofRecord`). The tile shows assigned, proving, submitted, paid.
|
||||
//!
|
||||
//! Where the prover runs: macOS runs the host next to the engine on the CPU (slow, shown as slow). Windows runs
|
||||
//! it inside WSL2 (SP1's CUDA prover is Linux-only): the engine looks for `~/igneum-prove/target/release/
|
||||
//! igneum-prove-host` in the Ubuntu-24.04 distribution; without it the tile says "proving needs the WSL2 setup,
|
||||
//! 20 minutes, Set up" and Set up runs proving/windows-wsl2/setup-wsl.sh from the payload (`wsl2/` next to the
|
||||
//! engine). Linux runs the host next to the engine. Everything the prover needs on a PC is in the payload or
|
||||
//! installed by that script; there is no other channel.
|
||||
|
||||
use crate::engine::Shared;
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// One shard the node lists for this machine's keys (`igneum_getAssignedShards`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Work {
|
||||
pub number: u64,
|
||||
pub hash: String,
|
||||
pub shard: u32,
|
||||
pub pgas: u64,
|
||||
pub tx_count: u64,
|
||||
pub assigned: bool,
|
||||
pub paid: bool,
|
||||
pub in_pool: bool,
|
||||
pub shard_wei: u128,
|
||||
/// The first of this machine's keys that is assigned (the label that signs).
|
||||
pub key_hash: String,
|
||||
}
|
||||
|
||||
/// Parses the node's work list. Newest first, as the node returns it.
|
||||
pub fn parse_work(v: &Value) -> Vec<Work> {
|
||||
let hexu = |x: &Value| x.as_str().and_then(|s| u128::from_str_radix(s.trim_start_matches("0x"), 16).ok()).unwrap_or(0);
|
||||
v.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.map(|w| Work {
|
||||
number: hexu(&w["number"]) as u64,
|
||||
hash: w["hash"].as_str().unwrap_or("").to_string(),
|
||||
shard: w["shard"].as_u64().unwrap_or(0) as u32,
|
||||
pgas: hexu(&w["pgas"]) as u64,
|
||||
tx_count: w["txCount"].as_u64().unwrap_or(0),
|
||||
assigned: w["assigned"].as_bool().unwrap_or(false),
|
||||
paid: !w["paid"].is_null(),
|
||||
in_pool: w["pool"].as_array().map(|p| !p.is_empty()).unwrap_or(false),
|
||||
shard_wei: hexu(&w["shardWei"]),
|
||||
key_hash: w["assignedKeys"].as_array().and_then(|k| k.first()).and_then(|k| k.as_str()).unwrap_or("").to_string(),
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The shard to prove next: assigned to one of our keys, unpaid, not already in the pool from us, not attempted
|
||||
/// in this session; the newest first (its exclusive window is the one still open), the smallest among equals.
|
||||
pub fn choose(work: &[Work], attempted: &HashSet<(String, u32)>) -> Option<Work> {
|
||||
let mut c: Vec<&Work> = work.iter().filter(|w| w.assigned && !w.paid && !w.in_pool && !attempted.contains(&(w.hash.clone(), w.shard))).collect();
|
||||
c.sort_by(|a, b| b.number.cmp(&a.number).then(a.pgas.cmp(&b.pgas)));
|
||||
c.first().map(|w| (*w).clone())
|
||||
}
|
||||
|
||||
/// A Windows path as WSL sees it: `C:\Users\x\f` -> `/mnt/c/Users/x/f`.
|
||||
pub fn wsl_path(p: &Path) -> String {
|
||||
let s = p.display().to_string().replace('\\', "/");
|
||||
if s.len() > 2 && s.as_bytes()[1] == b':' {
|
||||
format!("/mnt/{}{}", s[..1].to_ascii_lowercase(), &s[2..])
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// The identity labels this machine mines with (the vote keys the node assigns shards to): one per enabled
|
||||
/// card, `<label_base>-<card n>`, and `-1..N` per identity when a card runs more than one.
|
||||
pub fn labels(shared: &Shared) -> Vec<String> {
|
||||
let base = format!("{}-{}", if cfg!(target_os = "macos") { "mac" } else { "win" }, shared.runtime.id8());
|
||||
let st = shared.state.lock().unwrap();
|
||||
let mut out = Vec::new();
|
||||
for c in st.mining.cards.iter().filter(|c| c.enabled) {
|
||||
let label = format!("{base}-{}", c.index + 1);
|
||||
if c.identities > 1 {
|
||||
for i in 1..=c.identities {
|
||||
out.push(format!("{label}-{i}"));
|
||||
}
|
||||
} else {
|
||||
out.push(label);
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push(format!("{base}-1"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The network name the record signature is domain-separated with (`Params::network_name()`).
|
||||
pub fn chain_name(shared: &Shared) -> String {
|
||||
let r = &shared.runtime;
|
||||
match r.devnet_suffix {
|
||||
Some(s) if r.network == "devnet" => format!("igneum-devnet-{s}"),
|
||||
_ => format!("igneum-{}", r.network),
|
||||
}
|
||||
}
|
||||
|
||||
struct Tools {
|
||||
host: PathBuf,
|
||||
export: PathBuf,
|
||||
miner: PathBuf,
|
||||
/// Windows: the host and the exporter run inside WSL2 (paths are WSL paths then).
|
||||
wsl: bool,
|
||||
setup_script: Option<PathBuf>,
|
||||
cuda: bool,
|
||||
}
|
||||
|
||||
fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result<Value, String> {
|
||||
let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string();
|
||||
let tmp = std::env::temp_dir().join(format!("igneum-prover-{}-{}.json", std::process::id(), method));
|
||||
std::fs::write(&tmp, body).map_err(|e| e.to_string())?;
|
||||
let url = format!("http://127.0.0.1:{}", shared.runtime.evm_port());
|
||||
let out = crate::detect::run_timeout(
|
||||
Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", &timeout.as_secs().to_string(), "-X", "POST", &url, "-H", "Content-Type: application/json", "--data-binary", &format!("@{}", tmp.display())]),
|
||||
None,
|
||||
timeout + Duration::from_secs(2),
|
||||
);
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
let out = out.ok_or_else(|| format!("{method}: the node's RPC did not answer"))?;
|
||||
let v: Value = serde_json::from_str(&out).map_err(|e| format!("{method}: {e}"))?;
|
||||
if let Some(err) = v.get("error") {
|
||||
return Err(format!("{method}: {}", err.get("message").and_then(|m| m.as_str()).unwrap_or("error")));
|
||||
}
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
}
|
||||
|
||||
fn find_tools(bin_dir: &Path) -> Result<Tools, String> {
|
||||
let miner = bin_dir.join(if cfg!(windows) { "igneum-miner.exe" } else { "igneum-miner" });
|
||||
if cfg!(windows) {
|
||||
// the SP1 host runs inside WSL2 (Ubuntu-24.04): built there by setup-wsl.sh, or shipped as a Linux binary
|
||||
let probe = Command::new("wsl").args(["-d", "Ubuntu-24.04", "--", "bash", "-lc", "for f in ~/igneum-prove/target/release/igneum-prove-host /opt/igneum/igneum-prove-host; do [ -x \"$f\" ] && { echo \"$f\"; break; }; done; command -v nvidia-smi >/dev/null && echo cuda"]).output();
|
||||
let text = probe.map(|o| String::from_utf8_lossy(&o.stdout).to_string()).unwrap_or_default();
|
||||
let host = text.lines().find(|l| l.contains("igneum-prove-host")).map(|l| PathBuf::from(l.trim()));
|
||||
let setup = bin_dir.join("wsl2").join("setup-wsl.sh");
|
||||
match host {
|
||||
Some(host) => {
|
||||
let export = host.parent().map(|d| d.join("igneum-prove-export")).unwrap_or_default();
|
||||
Ok(Tools { host, export, miner, wsl: true, setup_script: setup.exists().then_some(setup), cuda: text.contains("cuda") })
|
||||
}
|
||||
None => Err(format!("proving needs the WSL2 setup, 20 minutes, Set up{}", if setup.exists() { "" } else { " (setup script missing from the payload)" })),
|
||||
}
|
||||
} else {
|
||||
let host = bin_dir.join("igneum-prove-host");
|
||||
let export = bin_dir.join("igneum-prove-export");
|
||||
if !host.exists() || !export.exists() {
|
||||
return Err(format!("igneum-prove-host and igneum-prove-export are not next to the engine ({})", bin_dir.display()));
|
||||
}
|
||||
Ok(Tools { host, export, miner, wsl: false, setup_script: None, cuda: false })
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the host or the exporter: directly, or through WSL on Windows. Returns (exit ok, output).
|
||||
fn run_tool(t: &Tools, exe: &Path, args: &[String], env: &[(&str, &str)], limit: Duration) -> (bool, String) {
|
||||
let mut cmd = if t.wsl {
|
||||
let mut c = Command::new("wsl");
|
||||
let envs: String = env.iter().map(|(k, v)| format!("{k}={v} ")).collect();
|
||||
let line = format!("{envs}{} {}", exe.display(), args.iter().map(|a| format!("'{a}'")).collect::<Vec<_>>().join(" "));
|
||||
c.args(["-d", "Ubuntu-24.04", "--", "bash", "-lc", &line]);
|
||||
c
|
||||
} else {
|
||||
let mut c = Command::new(exe);
|
||||
c.args(args);
|
||||
for (k, v) in env {
|
||||
c.env(k, v);
|
||||
}
|
||||
c
|
||||
};
|
||||
crate::platform::quiet(&mut cmd);
|
||||
let started = Instant::now();
|
||||
match crate::detect::run_timeout(&mut cmd, None, limit) {
|
||||
Some(out) => {
|
||||
let ok = out.lines().any(|l| l.starts_with("results written to") || l.contains("fixture written") || l.starts_with("wrote ")) || !out.contains("Error") && started.elapsed() < limit;
|
||||
(ok, out)
|
||||
}
|
||||
None => (false, format!("{} did not finish in {} s", exe.display(), limit.as_secs())),
|
||||
}
|
||||
}
|
||||
|
||||
fn set<F: FnOnce(&mut crate::state::ProvingState)>(shared: &Shared, f: F) {
|
||||
let mut st = shared.state.lock().unwrap();
|
||||
f(&mut st.proving);
|
||||
}
|
||||
|
||||
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.
|
||||
pub fn start(shared: Arc<Shared>, bin_dir: PathBuf) {
|
||||
let _ = BIN_DIR.set(bin_dir.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("igneum-prover".into())
|
||||
.spawn(move || loop_forever(shared, bin_dir))
|
||||
.expect("prover thread");
|
||||
}
|
||||
|
||||
fn loop_forever(shared: Arc<Shared>, bin_dir: PathBuf) {
|
||||
let mut attempted: HashSet<(String, u32)> = HashSet::new();
|
||||
let mut tools: Option<Tools> = None;
|
||||
let mut last_probe = Instant::now() - Duration::from_secs(600);
|
||||
let mut submitted: Vec<(u64, String, u32, u128)> = Vec::new();
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(10));
|
||||
let enabled = shared.settings.lock().unwrap().prove;
|
||||
let (synced, quitting) = {
|
||||
let st = shared.state.lock().unwrap();
|
||||
(st.node.synced, st.quitting)
|
||||
};
|
||||
if quitting {
|
||||
return;
|
||||
}
|
||||
if !enabled {
|
||||
set(&shared, |p| {
|
||||
p.enabled = false;
|
||||
p.status = "off".into();
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if tools.is_none() && last_probe.elapsed() >= Duration::from_secs(60) {
|
||||
last_probe = Instant::now();
|
||||
match find_tools(&bin_dir) {
|
||||
Ok(t) => {
|
||||
shared.log(&format!("prover: host {}{}{}", t.host.display(), if t.wsl { " (WSL2)" } else { "" }, if t.cuda { ", CUDA" } else { ", CPU (slow)" }));
|
||||
set(&shared, |p| {
|
||||
p.available = true;
|
||||
p.setup_hint = String::new();
|
||||
p.backend = if t.cuda { "cuda".into() } else { "cpu".into() };
|
||||
});
|
||||
tools = Some(t);
|
||||
}
|
||||
Err(e) => {
|
||||
set(&shared, |p| {
|
||||
p.enabled = true;
|
||||
p.available = false;
|
||||
p.setup_hint = e.clone();
|
||||
p.status = "setup".into();
|
||||
p.message = e;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(t) = tools.as_ref() else { continue };
|
||||
set(&shared, |p| {
|
||||
p.enabled = true;
|
||||
p.available = true;
|
||||
});
|
||||
if !synced {
|
||||
set(&shared, |p| {
|
||||
p.status = "waiting".into();
|
||||
p.message = "waiting for the node to sync".into();
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// 1. the keys and the work list
|
||||
let labels = labels(&shared);
|
||||
let mut keys: Vec<(String, String)> = Vec::new();
|
||||
for l in &labels {
|
||||
if let Some(out) = crate::detect::run_timeout(crate::platform::quiet(&mut Command::new(&t.miner)).args(["key-hash", l]), None, Duration::from_secs(10)) {
|
||||
if let Some(h) = out.lines().last().map(|s| s.trim().to_string()).filter(|s| s.len() == 64 || s.len() == 66) {
|
||||
keys.push((l.clone(), if h.starts_with("0x") { h } else { format!("0x{h}") }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if keys.is_empty() {
|
||||
set(&shared, |p| {
|
||||
p.status = "waiting".into();
|
||||
p.message = "no vote key (is igneum-miner next to the engine?)".into();
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let work = match evm_rpc(&shared, "igneum_getAssignedShards", json!([keys.iter().map(|(_, h)| h.clone()).collect::<Vec<_>>(), 60]), Duration::from_secs(10)) {
|
||||
Ok(v) => parse_work(&v),
|
||||
Err(e) => {
|
||||
set(&shared, |p| {
|
||||
p.status = "waiting".into();
|
||||
p.message = e;
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// paid shards among what we submitted
|
||||
for (n, h, s, wei) in submitted.clone() {
|
||||
if work.iter().any(|w| w.hash == h && w.shard == s && w.paid) {
|
||||
submitted.retain(|x| !(x.1 == h && x.2 == s));
|
||||
shared.event("proving", &format!("block {n} shard {s} paid {} IGN", wei as f64 / 1e18));
|
||||
set(&shared, |p| {
|
||||
p.paid += 1;
|
||||
p.paid_wei += wei;
|
||||
p.last_paid = format!("block {n} shard {s}");
|
||||
});
|
||||
}
|
||||
}
|
||||
let assigned = work.iter().filter(|w| w.assigned).count() as u32;
|
||||
set(&shared, |p| {
|
||||
p.assigned = assigned;
|
||||
p.keys = keys.len() as u32;
|
||||
});
|
||||
let Some(w) = choose(&work, &attempted) else {
|
||||
set(&shared, |p| {
|
||||
p.status = if submitted.is_empty() { "idle".into() } else { "submitted".into() };
|
||||
p.message = if assigned == 0 { "no shard assigned to this machine in the last 60 blocks".into() } else { "every assigned shard is proven or paid".into() };
|
||||
});
|
||||
continue;
|
||||
};
|
||||
attempted.insert((w.hash.clone(), w.shard));
|
||||
let label = keys.iter().find(|(_, h)| *h == w.key_hash).map(|(l, _)| l.clone()).unwrap_or_else(|| keys[0].0.clone());
|
||||
let payout = shared.settings.lock().unwrap().address.clone();
|
||||
if payout.len() != 42 {
|
||||
set(&shared, |p| {
|
||||
p.status = "waiting".into();
|
||||
p.message = "no rewards address".into();
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let started = Instant::now();
|
||||
set(&shared, |p| {
|
||||
p.status = "proving".into();
|
||||
p.current = format!("block {} shard {} ({} txs, {} pgas)", w.number, w.shard, w.tx_count, w.pgas);
|
||||
p.started_at = crate::platform::unix_now_f();
|
||||
p.message = "exporting the chain and cutting the shard".into();
|
||||
});
|
||||
shared.log(&format!("prover: block {} shard {} assigned to {label}: export, cut, prove ({}), sign, submit", w.number, w.shard, if t.cuda { "CUDA" } else { "CPU" }));
|
||||
// 2. export and cut
|
||||
let dir = shared.runtime.app_dir.join("proving");
|
||||
let _ = std::fs::create_dir_all(&dir);
|
||||
let seq = dir.join("seq.json");
|
||||
let fixture = dir.join(format!("block-{}.json", w.number));
|
||||
let results = dir.join(format!("results-{}-{}.json", w.number, w.shard));
|
||||
let outcome: Result<(), String> = (|| {
|
||||
let export = evm_rpc(&shared, "igneum_exportSegments", json!(["0x0", format!("{:#x}", w.number)]), Duration::from_secs(120))?;
|
||||
std::fs::write(&seq, export.to_string()).map_err(|e| e.to_string())?;
|
||||
let (seq_p, fix_p, res_p) = if t.wsl { (wsl_path(&seq), wsl_path(&fixture), wsl_path(&results)) } else { (seq.display().to_string(), fixture.display().to_string(), results.display().to_string()) };
|
||||
let (ok, out) = run_tool(t, &t.export, &[seq_p, w.number.to_string(), fix_p.clone()], &[], Duration::from_secs(600));
|
||||
if !ok || !fixture.exists() {
|
||||
return Err(format!("exporter: {}", out.lines().rev().find(|l| !l.trim().is_empty()).unwrap_or("failed")));
|
||||
}
|
||||
set(&shared, |p| p.message = if t.cuda { "proving on the GPU".into() } else { "proving on the CPU (slow)".into() });
|
||||
let prover_env = if t.cuda { "cuda" } else { "cpu" };
|
||||
let (ok, out) = run_tool(t, &t.host, &[fix_p, "--mode".into(), "compressed".into(), "--shard".into(), w.shard.to_string(), "--prover".into(), payout.clone(), "--out".into(), res_p], &[("SP1_PROVER", prover_env), ("RUST_LOG", "off")], Duration::from_secs(3 * 3600));
|
||||
let _ = std::fs::write(dir.join(format!("prove-{}-{}.log", w.number, w.shard)), &out);
|
||||
if !ok || !results.exists() {
|
||||
return Err(format!("prover: {}", out.lines().rev().find(|l| l.contains("RESULT") || l.contains("rror")).unwrap_or("failed")));
|
||||
}
|
||||
let res: Value = serde_json::from_str(&std::fs::read_to_string(&results).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
let statement = res["statement"].as_str().ok_or("no statement in the results")?.to_string();
|
||||
let proof_sha = res["proof_sha256"].as_str().ok_or("no proof hash in the results")?.to_string();
|
||||
let proof_file = res["proof_file"].as_str().ok_or("no proof file in the results")?.to_string();
|
||||
let proof_path = if t.wsl { PathBuf::from(proof_file.replace("/mnt/c/", "C:/")) } else { PathBuf::from(proof_file) };
|
||||
let secs = res["compressed_prove_seconds"].as_f64().unwrap_or(0.0);
|
||||
set(&shared, |p| {
|
||||
p.message = format!("proved in {secs:.0} s, signing and submitting");
|
||||
p.last_prove_s = secs;
|
||||
});
|
||||
// 3. sign and submit
|
||||
let sg = crate::detect::run_timeout(crate::platform::quiet(&mut Command::new(&t.miner)).args(["sign-record", &label, &chain_name(&shared), &w.hash, &w.number.to_string(), &w.shard.to_string(), &payout, &statement, &proof_sha]), None, Duration::from_secs(20)).ok_or("sign-record did not run")?;
|
||||
let signed: Value = serde_json::from_str(sg.lines().last().unwrap_or("")).map_err(|_| format!("sign-record: {}", sg.trim()))?;
|
||||
let record = signed["record"].as_str().ok_or("sign-record gave no record")?.to_string();
|
||||
let proof = std::fs::read(&proof_path).map_err(|e| format!("proof file {}: {e}", proof_path.display()))?;
|
||||
let proof_hex = format!("0x{}", proof.iter().map(|b| format!("{b:02x}")).collect::<String>());
|
||||
let r = evm_rpc(&shared, "igneum_submitProofRecord", json!([{ "record": record, "proof": proof_hex }]), Duration::from_secs(60))?;
|
||||
if !r["accepted"].as_bool().unwrap_or(false) {
|
||||
return Err(format!("record refused: {}", r["reason"].as_str().unwrap_or("?")));
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
match outcome {
|
||||
Ok(()) => {
|
||||
shared.event("proving", &format!("block {} shard {} proven and submitted in {:.0} s", w.number, w.shard, started.elapsed().as_secs_f64()));
|
||||
submitted.push((w.number, w.hash.clone(), w.shard, w.shard_wei));
|
||||
set(&shared, |p| {
|
||||
p.proved += 1;
|
||||
p.submitted += 1;
|
||||
p.status = "submitted".into();
|
||||
p.message = format!("block {} shard {} submitted; paid when a block carries it", w.number, w.shard);
|
||||
p.current = String::new();
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
shared.log(&format!("prover: block {} shard {}: {e}", w.number, w.shard));
|
||||
set(&shared, |p| {
|
||||
p.failed += 1;
|
||||
p.status = "idle".into();
|
||||
p.message = e;
|
||||
p.current = String::new();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows: runs the WSL2 setup from the payload (`wsl2/setup-wsl.sh` next to the engine) in a window of its own;
|
||||
/// the user watches it and reboots when it asks. Elsewhere there is nothing to set up.
|
||||
pub fn setup(shared: &Shared) -> Result<Value, String> {
|
||||
let bin_dir = BIN_DIR.get().cloned().unwrap_or_default();
|
||||
let script = bin_dir.join("wsl2").join("setup-wsl.sh");
|
||||
if !cfg!(windows) {
|
||||
return Err("nothing to set up on this platform: the prover runs next to the engine".into());
|
||||
}
|
||||
if !script.exists() {
|
||||
return Err(format!("setup script missing: {}", script.display()));
|
||||
}
|
||||
let line = format!("bash {}", wsl_path(&script));
|
||||
let mut c = Command::new("cmd");
|
||||
c.args(["/c", "start", "", "wsl", "-d", "Ubuntu-24.04", "--", "bash", "-lc", &line]);
|
||||
c.spawn().map_err(|e| e.to_string())?;
|
||||
shared.event("proving", "WSL2 prover setup started in its own window");
|
||||
Ok(json!({ "ok": true }))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn chooses_the_newest_unpaid_assigned_shard_not_yet_attempted() {
|
||||
let v: Value = serde_json::from_str(r#"[
|
||||
{"number":"0x20","hash":"0xa","shard":0,"pgas":"0x640","txCount":1,"assigned":true,"assignedKeys":["0xk1"],"paid":null,"pool":[],"shardWei":"0x10"},
|
||||
{"number":"0x1f","hash":"0xb","shard":1,"pgas":"0x10","txCount":0,"assigned":true,"assignedKeys":["0xk1"],"paid":null,"pool":[{"keyHash":"0xk1"}],"shardWei":"0x10"},
|
||||
{"number":"0x1e","hash":"0xc","shard":0,"pgas":"0x10","txCount":0,"assigned":true,"assignedKeys":["0xk2"],"paid":{"wei":"0x1"},"pool":[],"shardWei":"0x10"},
|
||||
{"number":"0x1d","hash":"0xd","shard":0,"pgas":"0x10","txCount":0,"assigned":false,"assignedKeys":[],"paid":null,"pool":[],"shardWei":"0x10"},
|
||||
{"number":"0x1c","hash":"0xe","shard":0,"pgas":"0x10","txCount":0,"assigned":true,"assignedKeys":["0xk2"],"paid":null,"pool":[],"shardWei":"0x10"}
|
||||
]"#).unwrap();
|
||||
let work = parse_work(&v);
|
||||
assert_eq!(work.len(), 5);
|
||||
assert_eq!(work[0].shard_wei, 16);
|
||||
let mut attempted = HashSet::new();
|
||||
let w = choose(&work, &attempted).unwrap();
|
||||
assert_eq!((w.number, w.shard, w.key_hash.as_str()), (32, 0, "0xk1"), "the newest assigned, unpaid, not in the pool");
|
||||
attempted.insert((w.hash.clone(), w.shard));
|
||||
let w = choose(&work, &attempted).unwrap();
|
||||
assert_eq!((w.number, w.shard), (28, 0), "block 31 is already in the pool, 30 is paid, 29 is not ours");
|
||||
attempted.insert((w.hash.clone(), w.shard));
|
||||
assert!(choose(&work, &attempted).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_paths() {
|
||||
assert_eq!(wsl_path(Path::new("C:\\Users\\[user]\\AppData\\Local\\igneum\\app\\proving\\seq.json")), "/mnt/c/Users/[user]/AppData/Local/igneum/app/proving/seq.json");
|
||||
assert_eq!(wsl_path(Path::new("/tmp/x")), "/tmp/x");
|
||||
}
|
||||
}
|
||||
|
|
@ -50,5 +50,6 @@
|
|||
"pow_epoch_blocks": 60,
|
||||
"pow_epoch_lead": 10,
|
||||
"pow_day_ms": 1440000,
|
||||
"difficulty_v2_activation_daa": 18446744073709551615
|
||||
"difficulty_v2_activation_daa": 18446744073709551615,
|
||||
"proving_v0_activation_daa": 18446744073709551615
|
||||
}
|
||||
|
|
|
|||
|
|
@ -189,15 +189,20 @@ pub struct RangeOutcome {
|
|||
/// Executes a contiguous range of a segment's transactions on `db` from `carry_in` (design 1.2 and 5.1): the
|
||||
/// rewards first when given (the segment's first shard), then every transaction in order. `db` is the state
|
||||
/// before the range on entry and after it on return. With `roots`, the state root is computed at every boundary.
|
||||
pub fn execute_range(db: &mut IgneumDb, chain_id: u64, env: &FixtureEnv, rewards: Option<(&[(Address, U256)], U256)>, txs: &[ShardTx], carry_in: Carry, roots: bool) -> RangeOutcome {
|
||||
pub fn execute_range(db: &mut IgneumDb, chain_id: u64, env: &FixtureEnv, rewards: Option<(&[(Address, U256)], U256, &[(Address, U256)])>, txs: &[ShardTx], carry_in: Carry, roots: bool) -> RangeOutcome {
|
||||
let base_fee_exec = env.base_fee_exec as u128;
|
||||
let base_fee_proving = env.base_fee_proving as u128;
|
||||
|
||||
if let Some((rewards, pool)) = rewards {
|
||||
if let Some((rewards, pool, payouts)) = rewards {
|
||||
for (miner, wei) in rewards {
|
||||
db.add_balance(*miner, *wei);
|
||||
}
|
||||
db.add_balance(PROVING_POOL_ADDRESS, pool);
|
||||
// proving v0 payouts (spec 7.7), as the node applies them: from the escrow, saturating
|
||||
for (to, wei) in payouts {
|
||||
db.sub_balance(PROVING_POOL_ADDRESS, *wei);
|
||||
db.add_balance(*to, *wei);
|
||||
}
|
||||
}
|
||||
|
||||
let mut executed: Vec<ExecutedReceipt> = Vec::new();
|
||||
|
|
@ -381,7 +386,7 @@ pub struct BlockOutcome {
|
|||
/// recorded after every transaction.
|
||||
pub fn execute_block(db: &mut IgneumDb, f: &crate::fixture::BlockFixture) -> BlockOutcome {
|
||||
let txs = f.flatten();
|
||||
let out = execute_range(db, f.chain_id, &f.env, Some((&f.rewards, f.proving_pool_credit)), &txs, Carry::default(), true);
|
||||
let out = execute_range(db, f.chain_id, &f.env, Some((&f.rewards, f.proving_pool_credit, &f.payouts)), &txs, Carry::default(), true);
|
||||
let state_root = db.state_root();
|
||||
BlockOutcome {
|
||||
executed: out.executed,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ pub struct BlockFixture {
|
|||
pub rewards: Vec<(Address, U256)>,
|
||||
/// The 20% proving-pool share credited to the pool escrow for this segment, in wei.
|
||||
pub proving_pool_credit: U256,
|
||||
/// Proving v0 (spec 7.7): the prover payouts the segment applies, pool escrow to payout address, in wei.
|
||||
/// Consensus data (from the proof records the segment's blocks carry), applied by shard 0 after the rewards.
|
||||
#[serde(default)]
|
||||
pub payouts: Vec<(Address, U256)>,
|
||||
pub blocks: Vec<IncludingBlock>,
|
||||
pub pre_state: Vec<AccountFixture>,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,22 +32,24 @@ pub fn shard_id(segment: &B256, index: u32) -> B256 {
|
|||
|
||||
/// Cuts the trace greedily: a shard grows while the next transaction keeps it at or under `budget`; a
|
||||
/// transaction that alone exceeds the budget becomes its own shard. A segment with no transactions is one
|
||||
/// empty shard (it still carries the rewards). `pre_root` and `pre_carry` describe the state before the first
|
||||
/// transaction; every boundary must carry its state root.
|
||||
pub fn plan(pre_root: B256, pre_carry: Carry, boundaries: &[Boundary], budget: u64) -> Vec<ShardSpec> {
|
||||
let root_at = |i: usize| -> B256 {
|
||||
/// empty shard (it still carries the rewards and payouts, so its post-root is `post_root`, the segment's root
|
||||
/// after them, not `pre_root`). `pre_root` and `pre_carry` describe the state before the rewards; every
|
||||
/// boundary must carry its state root. The same cut as the node's `igneum_exec::proving::cut`.
|
||||
pub fn plan(pre_root: B256, post_root: B256, pre_carry: Carry, boundaries: &[Boundary], budget: u64) -> Vec<ShardSpec> {
|
||||
let n = boundaries.len();
|
||||
let pre_root_at = |i: usize| -> B256 {
|
||||
if i == 0 {
|
||||
pre_root
|
||||
} else {
|
||||
boundaries[i - 1].state_root.expect("the planner needs the state root at every boundary")
|
||||
}
|
||||
};
|
||||
let post_root_at = |i: usize| -> B256 { if i == n { post_root } else { pre_root_at(i) } };
|
||||
let carry_at = |i: usize| -> Carry { if i == 0 { pre_carry } else { boundaries[i - 1].carry } };
|
||||
let pgas_at = |i: usize| -> u64 { if i == 0 { 0 } else { boundaries[i - 1].pgas } };
|
||||
let gas_at = |i: usize| -> u64 { if i == 0 { 0 } else { boundaries[i - 1].gas } };
|
||||
|
||||
let mut shards = Vec::new();
|
||||
let n = boundaries.len();
|
||||
let mut start = 0usize;
|
||||
let mut index = 0u32;
|
||||
loop {
|
||||
|
|
@ -73,8 +75,8 @@ pub fn plan(pre_root: B256, pre_carry: Carry, boundaries: &[Boundary], budget: u
|
|||
over_budget: over,
|
||||
carry_in: carry_at(start),
|
||||
carry_out: carry_at(end),
|
||||
pre_root: root_at(start),
|
||||
post_root: root_at(end),
|
||||
pre_root: pre_root_at(start),
|
||||
post_root: post_root_at(end),
|
||||
});
|
||||
index += 1;
|
||||
if end >= n {
|
||||
|
|
@ -103,7 +105,7 @@ mod tests {
|
|||
#[test]
|
||||
fn cuts_at_the_budget_and_isolates_an_oversized_transaction() {
|
||||
let b = boundaries(&[300, 300, 500, 1200, 100, 100]);
|
||||
let s = plan(B256::ZERO, Carry::default(), &b, 1000);
|
||||
let s = plan(B256::ZERO, B256::with_last_byte(6), Carry::default(), &b, 1000);
|
||||
let ranges: Vec<(u32, u32, bool)> = s.iter().map(|x| (x.tx_start, x.tx_end, x.over_budget)).collect();
|
||||
assert_eq!(ranges, vec![(0, 2, false), (2, 3, false), (3, 4, true), (4, 6, false)]);
|
||||
assert_eq!(s[0].pre_root, B256::ZERO);
|
||||
|
|
@ -114,8 +116,10 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn an_empty_segment_is_one_shard() {
|
||||
let s = plan(B256::ZERO, Carry::default(), &[], 1000);
|
||||
let s = plan(B256::ZERO, B256::with_last_byte(9), Carry::default(), &[], 1000);
|
||||
assert_eq!(s.len(), 1);
|
||||
assert_eq!((s[0].tx_start, s[0].tx_end), (0, 0));
|
||||
assert_eq!(s[0].pre_root, B256::ZERO);
|
||||
assert_eq!(s[0].post_root, B256::with_last_byte(9), "the empty shard ends at the root after the rewards");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,9 @@ pub struct ShardInput {
|
|||
/// Applied by shard 0 only (the segment's rewards precede its first transaction).
|
||||
pub rewards: Vec<(Address, U256)>,
|
||||
pub proving_pool_credit: U256,
|
||||
/// Applied by shard 0 only, after the rewards (proving v0 payouts, spec 7.7).
|
||||
#[serde(default)]
|
||||
pub payouts: Vec<(Address, U256)>,
|
||||
pub shard_index: u32,
|
||||
pub txs: Vec<ShardTx>,
|
||||
pub carry_in: Carry,
|
||||
|
|
@ -104,7 +107,7 @@ impl ShardOutput {
|
|||
/// The statement, as the guest runs it and as the host checks it natively first.
|
||||
pub fn shard_statement(input: &ShardInput) -> ShardOutput {
|
||||
let (mut db, pre_root) = witness::load(&input.witness, &input.block_hashes);
|
||||
let rewards = (input.shard_index == 0).then_some((&input.rewards[..], input.proving_pool_credit));
|
||||
let rewards = (input.shard_index == 0).then_some((&input.rewards[..], input.proving_pool_credit, &input.payouts[..]));
|
||||
let out = execute_range(&mut db, input.chain_id, &input.env, rewards, &input.txs, input.carry_in, false);
|
||||
let post_root = witness::post_root(&db, &input.witness);
|
||||
ShardOutput {
|
||||
|
|
@ -146,13 +149,13 @@ pub fn build_shards(block: &crate::fixture::BlockFixture, budget: u64, prover: A
|
|||
let pre_root = db.state_root();
|
||||
let txs = block.flatten();
|
||||
let outcome = execute_block(&mut db, block);
|
||||
let specs = crate::plan::plan(pre_root, Carry::default(), &outcome.boundaries, budget);
|
||||
let specs = crate::plan::plan(pre_root, outcome.state_root, Carry::default(), &outcome.boundaries, budget);
|
||||
|
||||
let mut state = load_pre_state(block);
|
||||
let mut shards = Vec::with_capacity(specs.len());
|
||||
for spec in specs {
|
||||
let range = &txs[spec.tx_start as usize..spec.tx_end as usize];
|
||||
let rewards = (spec.index == 0).then_some((&block.rewards[..], block.proving_pool_credit));
|
||||
let rewards = (spec.index == 0).then_some((&block.rewards[..], block.proving_pool_credit, &block.payouts[..]));
|
||||
let pre = state.clone();
|
||||
state.start_log();
|
||||
let _ = execute_range(&mut state, block.chain_id, &block.env, rewards, range, spec.carry_in, false);
|
||||
|
|
@ -164,6 +167,7 @@ pub fn build_shards(block: &crate::fixture::BlockFixture, budget: u64, prover: A
|
|||
block_hashes: block.block_hashes.clone(),
|
||||
rewards: if spec.index == 0 { block.rewards.clone() } else { Vec::new() },
|
||||
proving_pool_credit: if spec.index == 0 { block.proving_pool_credit } else { U256::ZERO },
|
||||
payouts: if spec.index == 0 { block.payouts.clone() } else { Vec::new() },
|
||||
shard_index: spec.index,
|
||||
txs: range.to_vec(),
|
||||
carry_in: spec.carry_in,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,8 @@ fn fixture_of(export: &Value, segments: &[Value], n: usize, hashes: &[(u64, B256
|
|||
};
|
||||
let rewards = seg["rewards"].as_array().context("rewards")?.iter().map(|r| Ok((addr(&r["address"])?, u256(&r["wei"])?))).collect::<Result<Vec<_>>>()?;
|
||||
let proving_pool_credit: U256 = seg["provingPoolCredit"].as_str().context("provingPoolCredit")?.parse()?;
|
||||
// proving v0 payouts (spec 7.7); an export from a node before proving v0 has none
|
||||
let payouts = seg["payouts"].as_array().map(|a| a.iter().map(|r| Ok((addr(&r["address"])?, u256(&r["wei"])?))).collect::<Result<Vec<_>>>()).transpose()?.unwrap_or_default();
|
||||
let pre_state = db
|
||||
.addresses()
|
||||
.into_iter()
|
||||
|
|
@ -80,6 +82,7 @@ fn fixture_of(export: &Value, segments: &[Value], n: usize, hashes: &[(u64, B256
|
|||
block_hashes: hashes.iter().rev().take(256).rev().copied().collect(),
|
||||
rewards,
|
||||
proving_pool_credit,
|
||||
payouts,
|
||||
blocks: blocks_of(seg)?,
|
||||
pre_state,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ bincode.workspace = true
|
|||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
hex.workspace = true
|
||||
sha2 = "0.10"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "time"] }
|
||||
|
||||
[build-dependencies]
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ use igneum_prove_core::agg::{self, AggInput, BlockOutput};
|
|||
use igneum_prove_core::shard::{build_shards, shard_statement, BuiltShard, ShardInput, ShardOutput};
|
||||
use igneum_prove_core::Fixture;
|
||||
use proof_system::{ProofSystem, SegmentClaim, ShardWitness, Sp1ProofSystem, Sp1ShardProof, StubProofSystem};
|
||||
use sha2::Digest;
|
||||
use sp1_sdk::{include_elf, Elf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
|
|
@ -63,9 +64,13 @@ fn main() -> Result<()> {
|
|||
|
||||
fn run() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let path = args.get(1).filter(|a| !a.starts_with("--")).context("usage: igneum-prove-host <fixture.json> [--mode native|execute|shard|block|all] [--shard N] [--prover 0x..] [--out results.json]")?;
|
||||
let arg = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned();
|
||||
let mode = arg("--mode").unwrap_or_else(|| "all".into());
|
||||
if mode == "verify" {
|
||||
// proving v0 (spec 7.7): the node's proof pool verifies a submitted shard proof off the consensus path
|
||||
return run_verify(&arg("--proof").context("--proof <file>")?, &arg("--statement").context("--statement 0x<keccak of the public values>")?);
|
||||
}
|
||||
let path = args.get(1).filter(|a| !a.starts_with("--")).context("usage: igneum-prove-host <fixture.json> [--mode native|execute|shard|compressed|block|all] [--shard N] [--prover 0x..] [--out results.json]; igneum-prove-host --mode verify --proof <file> --statement 0x..")?;
|
||||
let shard_index: usize = arg("--shard").map(|s| s.parse()).transpose()?.unwrap_or(0);
|
||||
let prover: Address = arg("--prover").map(|s| s.parse()).transpose()?.unwrap_or_else(|| Address::from_slice(&[0x19; 20]));
|
||||
let out_path = arg("--out");
|
||||
|
|
@ -199,6 +204,7 @@ fn run() -> Result<()> {
|
|||
let r = match mode.as_str() {
|
||||
"execute" => run_execute(&sp1, &shards, block.env.parent_hash, &mut results),
|
||||
"shard" => run_shard(&sp1, &shards, shard_index, out_path.as_deref(), &mut results),
|
||||
"compressed" => run_compressed(&sp1, &shards, shard_index, out_path.as_deref(), &mut results),
|
||||
"block" => run_block(&sp1, &shards, &claim, out_path.as_deref(), &mut results),
|
||||
"all" => run_shard(&sp1, &shards, shard_index, out_path.as_deref(), &mut results).and_then(|_| run_block(&sp1, &shards, &claim, out_path.as_deref(), &mut results)),
|
||||
other => Err(anyhow!("unknown mode {other}")),
|
||||
|
|
@ -342,6 +348,77 @@ fn run_shard(sp1: &Sp1ProofSystem, shards: &[BuiltShard], index: usize, out_dir:
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Proving v0 (spec 7.7): the prover's mode. Execute (the cycle count), then the compressed proof of one shard,
|
||||
/// verified; writes `<out dir>/block-N-shard-i-compressed.bin` and records the statement (keccak of the public
|
||||
/// values, what the proof record carries) and the proof's SHA-256 (the record's `proof_hash`).
|
||||
fn run_compressed(sp1: &Sp1ProofSystem, shards: &[BuiltShard], index: usize, out_dir: Option<&str>, results: &mut serde_json::Map<String, serde_json::Value>) -> Result<()> {
|
||||
let s = shards.get(index).ok_or_else(|| anyhow!("shard {index} is not in the plan ({} shards)", shards.len()))?;
|
||||
let i = s.output.shard_index;
|
||||
results.insert("shard_index".into(), i.into());
|
||||
stage(&format!("execute shard {i}"));
|
||||
let (out, report, dt) = sp1.execute_shard(&s.input)?;
|
||||
check_shard_output(&out, &s.output)?;
|
||||
let cycles = report.total_instruction_count();
|
||||
println!("RESULT execute shard {i}: {} cycles, {:.2} s at {}", cycles, dt.as_secs_f64(), now());
|
||||
results.insert("cycles".into(), cycles.into());
|
||||
results.insert("execute_seconds".into(), dt.as_secs_f64().into());
|
||||
|
||||
stage(&format!("compressed shard {i}"));
|
||||
let proof = sp1.prove_shard(&ShardWitness { input: s.input.clone() })?;
|
||||
let dt = sp1.last_timing("compressed").unwrap_or_default();
|
||||
let (ok, vdt) = sp1.verify_shard(&proof.proof, &s.output);
|
||||
let bytes = bincode::serialize(&proof.proof)?;
|
||||
let statement = alloy_primitives::keccak256(s.output.to_bytes());
|
||||
let proof_hash: [u8; 32] = sha2::Sha256::digest(&bytes).into();
|
||||
println!("RESULT compressed shard {i}: prove {:.1} s, proof {} bytes, verify {:.3} s, {}; statement {} proof sha256 0x{} prover {} at {}", dt.as_secs_f64(), bytes.len(), vdt.as_secs_f64(), if ok { "VERIFIED" } else { "VERIFY FAILED" }, statement, hex::encode(proof_hash), proof.output.prover, now());
|
||||
if !ok {
|
||||
bail!("compressed proof of shard {i} did not verify");
|
||||
}
|
||||
results.insert("compressed_prove_seconds".into(), dt.as_secs_f64().into());
|
||||
results.insert("compressed_verify_seconds".into(), vdt.as_secs_f64().into());
|
||||
results.insert("compressed_proof_bytes".into(), bytes.len().into());
|
||||
results.insert("statement".into(), statement.to_string().into());
|
||||
results.insert("proof_sha256".into(), format!("0x{}", hex::encode(proof_hash)).into());
|
||||
results.insert("block_hash".into(), s.input.env.hash.to_string().into());
|
||||
results.insert("number".into(), s.input.env.number.into());
|
||||
results.insert("prover".into(), proof.output.prover.to_string().into());
|
||||
let dir = out_dir.and_then(|p| std::path::Path::new(p).parent().map(|d| d.to_path_buf())).unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
let file = dir.join(format!("block-{}-shard-{i}-compressed.bin", s.input.env.number));
|
||||
std::fs::write(&file, &bytes)?;
|
||||
results.insert("proof_file".into(), file.display().to_string().into());
|
||||
println!("proof written to {}", file.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `--mode verify --proof <file> --statement 0x..`: loads the SP1 shard verifying key, verifies the compressed
|
||||
/// proof against it and checks that the keccak of its public values is the statement. Exit 0 = verified,
|
||||
/// 3 = not verified (what the node's proof pool reads).
|
||||
fn run_verify(proof_path: &str, statement: &str) -> Result<()> {
|
||||
let bytes = std::fs::read(proof_path).with_context(|| format!("read {proof_path}"))?;
|
||||
let want: B256 = statement.parse().context("statement is not 32 bytes of hex")?;
|
||||
stage("setup");
|
||||
let t = Instant::now();
|
||||
let sp1 = Sp1ProofSystem::from_env(SHARD_ELF, AGG_ELF)?;
|
||||
println!("RESULT setup: {:.2} s, shard program id {} at {}", t.elapsed().as_secs_f64(), sp1.program_id(), now());
|
||||
stage("verify");
|
||||
let t = Instant::now();
|
||||
let proof: sp1_sdk::SP1ProofWithPublicValues = bincode::deserialize(&bytes).context("the file is not a bincode SP1 proof")?;
|
||||
let got = alloy_primitives::keccak256(proof.public_values.as_slice());
|
||||
let output = ShardOutput::from_bytes(proof.public_values.as_slice());
|
||||
let ok = sp1.verify_shard_bytes(&proof) && got == want && output.is_some();
|
||||
let dt = t.elapsed().as_secs_f64();
|
||||
match &output {
|
||||
Some(o) => println!("RESULT verify: {} in {dt:.3} s; block {} shard {} prover {} statement {got} (want {want}) proof {} bytes at {}", if ok { "VERIFIED" } else { "NOT VERIFIED" }, o.number, o.shard_index, o.prover, bytes.len(), now()),
|
||||
None => println!("RESULT verify: NOT VERIFIED in {dt:.3} s; public values are not a shard statement at {}", now()),
|
||||
}
|
||||
drop(sp1);
|
||||
if ok {
|
||||
Ok(())
|
||||
} else {
|
||||
std::process::exit(3)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_block(sp1: &Sp1ProofSystem, shards: &[BuiltShard], claim: &SegmentClaim, out_dir: Option<&str>, results: &mut serde_json::Map<String, serde_json::Value>) -> Result<()> {
|
||||
let block_t = Instant::now();
|
||||
let mut proofs: Vec<Sp1ShardProof> = Vec::with_capacity(shards.len());
|
||||
|
|
|
|||
|
|
@ -243,6 +243,11 @@ impl Sp1ProofSystem {
|
|||
Ok((proof, dt))
|
||||
}
|
||||
|
||||
/// The cryptographic check alone (the public values are the caller's to compare).
|
||||
pub fn verify_shard_bytes(&self, proof: &SP1ProofWithPublicValues) -> bool {
|
||||
self.client.verify(proof, &self.shard_vk, None).is_ok()
|
||||
}
|
||||
|
||||
pub fn verify_shard(&self, proof: &SP1ProofWithPublicValues, expected: &ShardOutput) -> (bool, Duration) {
|
||||
let t = Instant::now();
|
||||
let ok = self.client.verify(proof, &self.shard_vk, None).is_ok();
|
||||
|
|
|
|||
219
tools/proving-v0/run.mjs
Normal file
219
tools/proving-v0/run.mjs
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
#!/usr/bin/env node
|
||||
// Proving v0 end to end on a private 3-node test network (spec 7.7, docs/plans/proving-v0.md): ports 29800 and up,
|
||||
// network igneum-devnet-955, data under /tmp/igneum-proving-v0, infra/fast-time's 60x profile with
|
||||
// skip_proof_of_work and proving_v0_activation_daa set. Three voting vmine producers (the proving build's
|
||||
// igneum-miner) share 1 block/s; v0 names a funded EVM address. Node 0 verifies SP1 proofs with the real host
|
||||
// (IGNEUM_PROOF_VERIFIER); nodes 1 and 2 run in trust mode, so the relay and the verifier are both exercised.
|
||||
//
|
||||
// Steps, each timed: wait for the activation DAA, send one transfer, export the chain and cut the fixture with
|
||||
// igneum-prove-export, check the exporter's plan against the node's igneum_getShardPlan, prove the shard on the
|
||||
// CPU with igneum-prove-host --mode compressed, sign the record with igneum-miner sign-record (v0's vote key),
|
||||
// submit it to node 1, watch node 0 verify it, watch a block carry it, and check the payout address's balance.
|
||||
// Never touches the live devnet (26610/26611, 26640/28640) or the fast-time simnet (29500+).
|
||||
//
|
||||
// node tools/proving-v0/run.mjs [--secs 1500] [--activation 60] [--empty] (--empty proves the newest empty segment instead)
|
||||
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, rmSync, writeFileSync, readFileSync, openSync, existsSync } from 'node:fs';
|
||||
import { connectRpc } from '../finality-attacks/lib/rpc.mjs';
|
||||
import { privateKeyToAccount } from '../prove-fixtures/node_modules/viem/_esm/accounts/index.js';
|
||||
|
||||
const ROOT = new URL('../../', import.meta.url).pathname;
|
||||
const FILE = `${ROOT}infra/fast-time/override-60x.json`;
|
||||
const REL = process.env.IGNEUM_PROVING_BIN || `${ROOT}vendor/igneum-node-proving/target/release`;
|
||||
const IGNEUMD = `${REL}/igneumd`;
|
||||
const MINER = `${REL}/igneum-miner`;
|
||||
const PROVE = process.env.IGNEUM_PROVE_BIN || `${ROOT}proving/igneum-prove/target/release`;
|
||||
const HOST = `${PROVE}/igneum-prove-host`;
|
||||
const EXPORT = `${PROVE}/igneum-prove-export`;
|
||||
const TMP = '/tmp/igneum-proving-v0';
|
||||
const BASE = 29800, SUFFIX = 955, CHAIN_NAME = `igneum-devnet-${SUFFIX}`, CHAIN_ID = 4463;
|
||||
const args = process.argv.slice(2);
|
||||
const SECS = +(args[args.indexOf('--secs') + 1] || 1500);
|
||||
const ACTIVATION = +(args[args.indexOf('--activation') + 1] || 60);
|
||||
const EMPTY = args.includes('--empty');
|
||||
const started = [];
|
||||
const t0 = Date.now();
|
||||
const since = () => ((Date.now() - t0) / 1000).toFixed(1);
|
||||
const log = (...a) => console.log(new Date().toISOString().slice(11, 23), `t=${since()}s`, ...a);
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
for (const b of [IGNEUMD, MINER, HOST, EXPORT]) if (!existsSync(b)) { console.error(`missing ${b}`); process.exit(2); }
|
||||
|
||||
// the funded account: v0's payout address (a throwaway key from tools/prove-fixtures/gen.mjs)
|
||||
const funded = privateKeyToAccount('0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d');
|
||||
const PAYOUT = '0x4242424242424242424242424242424242424242';
|
||||
|
||||
rmSync(TMP, { recursive: true, force: true }); mkdirSync(TMP, { recursive: true });
|
||||
const override = `${TMP}/override.json`;
|
||||
writeFileSync(override, JSON.stringify({ ...JSON.parse(readFileSync(FILE, 'utf8')), skip_proof_of_work: true, proving_v0_activation_daa: ACTIVATION }));
|
||||
|
||||
class Node {
|
||||
constructor(i, connect = [], env = {}) {
|
||||
this.i = i; this.grpcPort = BASE + i * 10; this.p2pPort = BASE + i * 10 + 1; this.jsonPort = BASE + i * 10 + 2; this.evmPort = BASE + i * 10 + 3;
|
||||
this.connect = connect; this.env = env; this.dir = `${TMP}/n${i}`; this.logFile = `${this.dir}/node.log`;
|
||||
}
|
||||
get grpc() { return `grpc://127.0.0.1:${this.grpcPort}`; }
|
||||
get evm() { return `http://127.0.0.1:${this.evmPort}`; }
|
||||
async start() {
|
||||
mkdirSync(this.dir, { recursive: true });
|
||||
const a = ['--devnet', `--devnet-suffix=${SUFFIX}`, '--nodnsseed', '--disable-upnp', '--nologfiles', '--enable-unsynced-mining', '--utxoindex', '--unsaferpc',
|
||||
`--appdir=${this.dir}`, `--rpclisten=127.0.0.1:${this.grpcPort}`, `--rpclisten-json=127.0.0.1:${this.jsonPort}`, `--evm-rpclisten=127.0.0.1:${this.evmPort}`,
|
||||
`--listen=127.0.0.1:${this.p2pPort}`, `--override-params-file=${override}`, '--loglevel=info', '--yes'];
|
||||
if (this.connect.length) a.push(`--connect=${this.connect.join(',')}`); else a.push('--outpeers=0');
|
||||
const out = openSync(this.logFile, 'a');
|
||||
this.proc = spawn(IGNEUMD, a, { stdio: ['ignore', out, out], env: { ...process.env, ...this.env } });
|
||||
started.push(this.proc);
|
||||
await sleep(800);
|
||||
this.rpc = await connectRpc(`ws://127.0.0.1:${this.jsonPort}`);
|
||||
log(`n${this.i} up pid ${this.proc.pid} json ${this.jsonPort} evm ${this.evmPort} p2p ${this.p2pPort} env ${JSON.stringify(this.env)}`);
|
||||
return this;
|
||||
}
|
||||
async eth(method, params = []) {
|
||||
const r = await fetch(this.evm, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
|
||||
const j = await r.json();
|
||||
if (j.error) throw new Error(`${method}: ${j.error.message}`);
|
||||
return j.result;
|
||||
}
|
||||
grepLog(re) { try { return readFileSync(this.logFile, 'utf8').split('\n').filter(l => re.test(l)); } catch { return []; } }
|
||||
}
|
||||
function miner(argv, name) {
|
||||
const out = openSync(`${TMP}/${name}.log`, 'a');
|
||||
const p = spawn(MINER, argv, { stdio: ['ignore', out, out] });
|
||||
started.push(p);
|
||||
return p;
|
||||
}
|
||||
async function stopAll() {
|
||||
for (const p of started.reverse()) { try { p.kill('SIGINT'); } catch { } }
|
||||
await sleep(1500);
|
||||
for (const p of started) { try { p.kill('SIGKILL'); } catch { } }
|
||||
}
|
||||
process.on('SIGINT', async () => { await stopAll(); process.exit(130); });
|
||||
const report = { activation: ACTIVATION, steps: {} };
|
||||
const step = (k, v) => { report.steps[k] = v; log(`STEP ${k}: ${JSON.stringify(v)}`); };
|
||||
function run(cmd, argv, env = {}) {
|
||||
const t = Date.now();
|
||||
const r = spawnSync(cmd, argv, { encoding: 'utf8', maxBuffer: 1 << 28, env: { ...process.env, ...env } });
|
||||
return { code: r.status, out: (r.stdout || '') + (r.stderr || ''), secs: (Date.now() - t) / 1000 };
|
||||
}
|
||||
|
||||
try {
|
||||
const n0 = await new Node(0, [], { IGNEUM_PROOF_VERIFIER: HOST, SP1_PROVER: 'cpu' }).start();
|
||||
const n1 = await new Node(1, [`127.0.0.1:${n0.p2pPort}`], { IGNEUM_PROOF_VERIFY: 'trust' }).start();
|
||||
const n2 = await new Node(2, [`127.0.0.1:${n0.p2pPort}`, `127.0.0.1:${n1.p2pPort}`], { IGNEUM_PROOF_VERIFY: 'trust' }).start();
|
||||
const nodes = [n0, n1, n2];
|
||||
log(`node 0 says: ${n0.grepLog(/Proving v0|proving v0/).join(' | ') || '(no proving line)'}`);
|
||||
|
||||
// three voters sharing 1 block/s; v0 pays the funded account
|
||||
nodes.forEach((n, i) => miner(['vmine', n.grpc, String(SECS), '--label', `v${i}`, '--share', String(1 / 3), '--bps', '1', ...(i === 0 ? ['--evm-address', funded.address] : [])], `vmine-v${i}`));
|
||||
const keyHashes = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
for (let k = 0; k < 50 && !keyHashes[i]; k++) { const m = (() => { try { return readFileSync(`${TMP}/vmine-v${i}.log`, 'utf8').match(/key=([0-9a-f]{64})/); } catch { return null; } })(); if (m) keyHashes[i] = '0x' + m[1]; else await sleep(200); }
|
||||
}
|
||||
log(`vote keys: ${keyHashes.join(' ')}`);
|
||||
const status0 = await n0.eth('igneum_getProvingStatus');
|
||||
log(`proving status n0: ${JSON.stringify(status0)}`);
|
||||
step('status', { activationDaa: status0.activationDaa, verifier: status0.verifier });
|
||||
|
||||
// 1. wait for the activation DAA (plus a margin: the sortition window needs dust blocks per key)
|
||||
let daa = 0;
|
||||
while (daa < ACTIVATION + 5) {
|
||||
await sleep(2000);
|
||||
const s = await n0.eth('igneum_getProvingStatus');
|
||||
daa = parseInt(s.tipDaa, 16);
|
||||
if (Math.round(+since()) % 10 === 0) log(`daa ${daa} active=${s.active} pool=${JSON.stringify(s.pool)}`);
|
||||
}
|
||||
step('activation', { daa, secs: +since() });
|
||||
|
||||
// 2. a transfer from the funded account, so a segment has one transaction
|
||||
const balance = BigInt(await n0.eth('eth_getBalance', [funded.address, 'latest']));
|
||||
log(`funded ${funded.address} balance ${balance} wei`);
|
||||
if (balance === 0n) throw new Error('the funded account has no rewards yet');
|
||||
const nonce = parseInt(await n0.eth('eth_getTransactionCount', [funded.address, 'latest']), 16);
|
||||
const raw = await funded.signTransaction({ type: 'eip1559', chainId: CHAIN_ID, nonce, to: '0x1111111111111111111111111111111111111111', value: 1_000_000_000_000_000n, gas: 21000n, maxFeePerGas: 20_000_000_000n, maxPriorityFeePerGas: 1_000_000_000n });
|
||||
const txHash = await n0.eth('eth_sendRawTransaction', [raw]);
|
||||
let receipt = null;
|
||||
for (let k = 0; k < 120 && !receipt; k++) { await sleep(1000); receipt = await n0.eth('eth_getTransactionReceipt', [txHash]); }
|
||||
if (!receipt) throw new Error('the transfer was not executed in 120 s');
|
||||
const txNumber = parseInt(receipt.blockNumber, 16);
|
||||
step('transfer', { txHash, number: txNumber, secs: +since() });
|
||||
|
||||
// 3. the work list of v0's key, and the shard to prove
|
||||
await sleep(1500);
|
||||
const work = await n0.eth('igneum_getAssignedShards', [[keyHashes[0]], 100]);
|
||||
const mine = work.filter(w => w.assigned);
|
||||
log(`assigned shards for v0 in the last 100 blocks: ${mine.length} of ${work.length} listed (${mine.slice(0, 5).map(w => `#${parseInt(w.number, 16)}/${w.shard} txs ${w.txCount}`).join(', ')} ...)`);
|
||||
let target = EMPTY ? mine.find(w => w.txCount === 0) : mine.find(w => parseInt(w.number, 16) === txNumber);
|
||||
if (!target) { log(`v0 is not assigned to block ${txNumber} (or no empty shard); taking the newest assigned shard`); target = mine[0]; }
|
||||
if (!target) throw new Error('v0 has no assigned shard');
|
||||
const number = parseInt(target.number, 16);
|
||||
const plan = await n0.eth('igneum_getShardPlan', [target.number]);
|
||||
const plan1 = await n1.eth('igneum_getShardPlan', [target.number]);
|
||||
if (JSON.stringify(plan.shards) !== JSON.stringify(plan1.shards)) throw new Error('nodes 0 and 1 disagree on the shard plan');
|
||||
step('plan', { number, hash: plan.hash, shards: plan.shards.length, eligibleKeys: plan.eligibleKeys, windowBlocks: plan.windowBlocks, assignees: plan.shards[target.shard].assignees.length, pgas: parseInt(plan.shards[target.shard].pgas, 16), shardWei: target.shardWei, sameOnNode1: true });
|
||||
|
||||
// 4. export and cut the fixture; the exporter's plan must be the node's
|
||||
const seq = await n0.eth('igneum_exportSegments', ['0x0', target.number]);
|
||||
writeFileSync(`${TMP}/seq.json`, JSON.stringify(seq));
|
||||
const fixture = `${TMP}/block-${number}.json`;
|
||||
const ex = run(EXPORT, [`${TMP}/seq.json`, String(number), fixture, '--source', `proving-v0 test network block ${number}`]);
|
||||
writeFileSync(`${TMP}/export.log`, ex.out);
|
||||
if (ex.code !== 0) throw new Error(`exporter failed (${ex.code}): ${ex.out.split('\n').slice(-5).join(' | ')}`);
|
||||
const fx = JSON.parse(readFileSync(fixture, 'utf8'));
|
||||
const fs0 = fx.plan.shards[target.shard], ns0 = plan.shards[target.shard];
|
||||
const same = fx.plan.shards.length === plan.shards.length && fs0.pre_root === ns0.preRoot && fs0.post_root === ns0.postRoot && fs0.link_in === ns0.linkIn && fs0.link_out === ns0.linkOut && fs0.receipts_root === ns0.receiptsRoot && fs0.pgas_used === parseInt(ns0.pgas, 16);
|
||||
step('export', { secs: ex.secs, exporterShards: fx.plan.shards.length, matchesNode: same, preRoot: fs0.pre_root, postRoot: fs0.post_root });
|
||||
if (!same) throw new Error(`the exporter's plan differs from the node's: ${JSON.stringify({ fs0, ns0 })}`);
|
||||
|
||||
// 5. prove the shard on the CPU (execute, then compressed)
|
||||
log(`proving block ${number} shard ${target.shard} on the CPU (SP1_PROVER=cpu); this takes minutes on a loaded Mac`);
|
||||
const results = `${TMP}/results-${number}-${target.shard}.json`;
|
||||
const pr = run(HOST, [fixture, '--mode', 'compressed', '--shard', String(target.shard), '--prover', PAYOUT, '--out', results], { SP1_PROVER: 'cpu', RUST_LOG: 'off' });
|
||||
writeFileSync(`${TMP}/prove.log`, pr.out);
|
||||
if (pr.code !== 0) throw new Error(`prover failed (${pr.code}): ${pr.out.split('\n').filter(l => /RESULT|error|Error/.test(l)).slice(-6).join(' | ')}`);
|
||||
const res = JSON.parse(readFileSync(results, 'utf8'));
|
||||
step('prove', { secs: pr.secs, cycles: res.cycles, executeSeconds: res.execute_seconds, compressedSeconds: res.compressed_prove_seconds, verifySeconds: res.compressed_verify_seconds, proofBytes: res.compressed_proof_bytes, statement: res.statement, proofSha256: res.proof_sha256 });
|
||||
|
||||
// 6. sign the record with v0's vote key and submit it to node 1 (trust mode), with the proof bytes
|
||||
const sg = run(MINER, ['sign-record', 'v0', CHAIN_NAME, plan.hash, String(number), String(target.shard), PAYOUT, res.statement, res.proof_sha256]);
|
||||
if (sg.code !== 0) throw new Error(`sign-record failed: ${sg.out}`);
|
||||
const signed = JSON.parse(sg.out.trim().split('\n').pop());
|
||||
if (signed.keyHash !== keyHashes[0].slice(2)) throw new Error(`sign-record key ${signed.keyHash} is not v0's ${keyHashes[0]}`);
|
||||
const proofHex = '0x' + readFileSync(res.proof_file).toString('hex');
|
||||
const sub = await n1.eth('igneum_submitProofRecord', [{ record: signed.record, proof: proofHex }]);
|
||||
step('submit', { to: 'n1', ...sub, secs: +since() });
|
||||
if (!sub.accepted) throw new Error(`record refused: ${sub.reason}`);
|
||||
|
||||
// 7. node 0 receives it over p2p and verifies the SP1 proof; then a block carries it and the segment pays
|
||||
let verified = null, paid = null, carriedBy = null, relayedAt = null, verifiedAt = null, paidAt = null;
|
||||
const deadline = Date.now() + 600_000;
|
||||
while (Date.now() < deadline && !(paid && verified)) {
|
||||
await sleep(1000);
|
||||
const r0 = await n0.eth('igneum_getProofRecords', [target.number]);
|
||||
const e0 = r0.pool.find(e => e.shard === target.shard);
|
||||
if (e0 && relayedAt == null) { relayedAt = +since(); log(`n0 holds the record over p2p (verified=${e0.verified})`); }
|
||||
if (e0 && e0.verified != null && verified == null) { verified = e0.verified; verifiedAt = +since(); log(`n0 verifier says ${e0.verified}: ${e0.note}`); }
|
||||
const paidRow = r0.paid && r0.paid[target.shard];
|
||||
if (paidRow && paid == null) { paid = paidRow; paidAt = +since(); carriedBy = (r0.carried.find(c => c.valid) || {}).carrier; log(`PAID on n0: ${JSON.stringify(paidRow)} carried by ${carriedBy}`); }
|
||||
if (Math.round(+since()) % 15 === 0) log(`waiting: pool ${JSON.stringify((e0 || {}).verified)} included ${(e0 || {}).includedIn || 'no'} paid ${paid ? 'yes' : 'no'}`);
|
||||
}
|
||||
step('relay_verify_pay', { relayedAt, verified, verifiedAt, paidAt, carriedBy, paid });
|
||||
if (!verified) throw new Error('node 0 did not verify the proof');
|
||||
if (!paid) throw new Error('no block carried the record within 10 minutes');
|
||||
// every node agrees on the payment, and the payout address holds the shard's part
|
||||
await sleep(3000);
|
||||
const agree = [];
|
||||
for (const n of nodes) { const r = await n.eth('igneum_getProofRecords', [target.number]); agree.push(r.paid && r.paid[target.shard] ? r.paid[target.shard].wei : null); }
|
||||
const bal = BigInt(await n0.eth('eth_getBalance', [PAYOUT, 'latest']));
|
||||
const pool = await n0.eth('igneum_getProvingStatus');
|
||||
step('payout', { paidWeiPerNode: agree, payoutBalanceWei: bal.toString(), shardWei: BigInt(target.shardWei).toString(), balanceEqualsShardWei: bal === BigInt(target.shardWei), poolBalanceWei: BigInt(pool.poolBalanceWei).toString(), paidShards: pool.paidShards });
|
||||
report.ok = bal === BigInt(target.shardWei) && agree.every(a => a === agree[0] && a != null);
|
||||
log(`RESULT proving v0 end to end: ${report.ok ? 'PASSED' : 'FAILED'} in ${since()} s`);
|
||||
} catch (e) {
|
||||
report.error = e.message;
|
||||
log(`FAILED: ${e.message}`);
|
||||
} finally {
|
||||
writeFileSync(`${TMP}/report.json`, JSON.stringify(report, null, 2));
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
await stopAll();
|
||||
process.exit(report.ok ? 0 : 1);
|
||||
}
|
||||
Loading…
Reference in a new issue