From 69682eeecccc60cdae6d8a45a79c3326b5b51bdc Mon Sep 17 00:00:00 2001 From: igneum-labs <337424239+igneum-labs@users.noreply.github.com> Date: Wed, 7 Oct 2026 09:59:57 +0000 Subject: [PATCH] execrpc: one gate for every execution-layer RPC call the app makes (ledger N7, main's rule for 0.3.19) A node before the exec RPC bounds fix (every 0.3.17 node) dies when a method that resolves a block number or indexes the record vector is asked while its exec follower holds no record; PC 1 crash-looped on two callers in one night (eth_getBlockByNumber from the clock sample, then igneum_getAssignedShards from the prover loop: 'panicked at igneum/exec/src/rpc.rs:808:35: range start index 1 out of range for slice of length 0'). Every caller (prover.rs's evm_rpc, update.rs's clock sample, extnode's rpc for chainfacts and the external-node probe) now goes through execrpc::call: SAFE_ON_EMPTY methods go out, GATED ones wait for igneum_getExecStatus's executedTipHash, an unclassified method is refused. The test every_caller_goes_through_the_gate scans src/ for JSON-RPC requests built elsewhere and for unclassified exec method names. Co-Authored-By: Claude Fable 5.1 --- app/igneum-app/src/execrpc.rs | 149 ++++++++++++++++++++++++++++++++++ app/igneum-app/src/extnode.rs | 11 +-- app/igneum-app/src/main.rs | 1 + app/igneum-app/src/prover.rs | 18 +--- app/igneum-app/src/update.rs | 31 ++----- 5 files changed, 159 insertions(+), 51 deletions(-) create mode 100644 app/igneum-app/src/execrpc.rs diff --git a/app/igneum-app/src/execrpc.rs b/app/igneum-app/src/execrpc.rs new file mode 100644 index 00000000..be4674c2 --- /dev/null +++ b/app/igneum-app/src/execrpc.rs @@ -0,0 +1,149 @@ +//! The one path to the node's execution-layer JSON-RPC (ledger N7, 7 October 2026, main's rule for 0.3.19). +//! +//! A node before the exec RPC bounds fix (every 0.3.17 node) dies when a method that resolves a block number or indexes +//! the record vector is asked while its exec follower holds no record: `rpc.rs` indexes `records[0]` (or slices +//! `records[1..=0]`) on an empty vector, the panic hook exits the process, and the app restarts it. PC 1 crash-looped on +//! two callers in one night (the clock sample's eth_getBlockByNumber, then the prover's igneum_getAssignedShards after the +//! node read synced seconds before a slow follower loaded). The node-side fix ships with the 0.3.20 node; a 0.3.19 app on a +//! 0.3.17 node must be safe by itself, so every exec RPC call the app makes goes through [`call`]: a method in +//! [`SAFE_ON_EMPTY`] goes out at once; any other waits until igneum_getExecStatus reports an executed tip. The unit test +//! below enumerates the callers: no other file may build an exec JSON-RPC request, and every method name in the tree must +//! be classified here, so a new caller or method cannot bypass the gate. +use serde_json::{json, Value}; +use std::process::Command; +use std::time::Duration; + +/// Methods that index nothing on an empty exec state (read from the 0.3.17 node's rpc.rs): safe at any time. +pub const SAFE_ON_EMPTY: &[&str] = &[ + "eth_chainId", "eth_blockNumber", "eth_syncing", "igneum_getExecStatus", "igneum_getProvingStatus", "igneum_getNodeInfo", +]; + +/// Methods the app sends that resolve a block number, index or slice the record vector, or simulate at a block: held until +/// the follower holds a record. Every method literal outside this module must be in one of the two lists. +pub const GATED: &[&str] = &[ + "eth_getBlockByNumber", "igneum_getAssignedShards", "igneum_getProofRecords", "igneum_getSegmentRecords", "igneum_getSegmentStatement", + "igneum_getProofBytes", "igneum_getSegmentProofBytes", "igneum_exportSegments", "igneum_submitProofRecord", "igneum_submitSegmentRecord", + "igneum_getFinalityWeights", +]; + +/// One JSON-RPC POST to 127.0.0.1: through curl (the engine carries no HTTP client); the body goes through a file +/// so a large export request is not an argument. The reply's `result` (null allowed), or the error's message. +fn post(evm_port: u16, method: &str, params: Value, timeout: Duration) -> Result { + let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string(); + let tmp = std::env::temp_dir().join(format!("igneum-rpc-{}-{}-{}.json", std::process::id(), method, crate::platform::unix_now_f() as u64)); + std::fs::write(&tmp, body).map_err(|e| e.to_string())?; + let url = format!("http://127.0.0.1:{evm_port}"); + let out = crate::detect::run_timeout( + Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", &timeout.as_secs().max(1).to_string(), "-X", "POST", &url, "-H", "Content-Type: application/json", "-d", &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)) +} + +/// True once the node's exec follower holds a record (igneum_getExecStatus's executedTipHash is set). Any error or an +/// unreachable node reads false: the gated call waits rather than asks. +pub fn has_record(evm_port: u16) -> bool { + match post(evm_port, "igneum_getExecStatus", json!([]), Duration::from_secs(5)) { + Ok(r) => status_has_record(&r), + Err(_) => false, + } +} + +/// The reading of an igneum_getExecStatus result: a record is held when executedTipHash is a non-empty string. +pub fn status_has_record(result: &Value) -> bool { + result.get("executedTipHash").and_then(|h| h.as_str()).map(|h| !h.is_empty()).unwrap_or(false) +} + +/// The gate: a safe method goes out; a gated one waits for a record; an unclassified method is refused (add it to a list). +pub fn call(evm_port: u16, method: &str, params: Value, timeout: Duration) -> Result { + if SAFE_ON_EMPTY.contains(&method) { + return post(evm_port, method, params, timeout); + } + if !GATED.contains(&method) { + return Err(format!("{method}: not classified in execrpc (safe on an empty state, or gated); add it before calling")); + } + if !has_record(evm_port) { + return Err(format!("{method}: the node's execution layer holds no record yet")); + } + post(evm_port, method, params, timeout) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_reading() { + assert!(status_has_record(&json!({"executedTip":"0x38a5","executedTipHash":"0xa3ae37ec"}))); + assert!(!status_has_record(&json!({"executedTip":"0x0","executedTipHash":null}))); + assert!(!status_has_record(&json!({}))); + assert!(!status_has_record(&json!({"executedTipHash":""}))); + } + + #[test] + fn lists_are_disjoint_and_sorted_enough() { + for m in GATED { + assert!(!SAFE_ON_EMPTY.contains(m), "{m} in both lists"); + } + } + + /// Ledger N7: every exec JSON-RPC request the app builds goes through this module, and every exec method name in the + /// tree is classified here. A new direct caller (a file that builds a "jsonrpc" POST) or an unclassified method fails. + #[test] + fn every_caller_goes_through_the_gate() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + // every eth_* or igneum_* identifier on a line (a hand scanner: no regex crate in this binary) + fn methods_in(line: &str) -> Vec { + // ASCII-only scan over char boundaries: a token of [A-Za-z0-9_] that starts with eth_ or igneum_ + let mut out = Vec::new(); + let mut token = String::new(); + let mut flush = |t: &mut String| { + if t.starts_with("eth_") || t.starts_with("igneum_") { out.push(t.clone()); } + t.clear(); + }; + for ch in line.chars() { + if ch.is_ascii_alphanumeric() || ch == '_' { token.push(ch); } else { flush(&mut token); } + } + flush(&mut token); + out + } + let mut offenders = Vec::new(); + let mut unclassified = Vec::new(); + for entry in std::fs::read_dir(&src).unwrap() { + let path = entry.unwrap().path(); + if path.extension().and_then(|e| e.to_str()) != Some("rs") { continue; } + let name = path.file_name().unwrap().to_string_lossy().to_string(); + let text = std::fs::read_to_string(&path).unwrap(); + // a JSON-RPC REQUEST names a method next to "jsonrpc" (replies and test fixtures carry "result" or "error"); + // only this module may build one + if name != "execrpc.rs" { + for (i, line) in text.lines().enumerate() { + let l = line.replace('\\', ""); + if l.contains("\"jsonrpc\"") && l.contains("\"method\"") && !l.contains("\"result\"") && !l.contains("\"error\"") { + offenders.push(format!("{name}:{}", i + 1)); + } + } + } + for (i, line) in text.lines().enumerate() { + if line.trim_start().starts_with("//") { continue; } + for m in methods_in(line) { + let m = m.as_str(); + // identifiers that are not RPC methods: crate and file names (igneum_app, igneum_miner, ...) carry no camel-case method part + let looks_like_method = m.contains("_get") || m.contains("_submit") || m.contains("_export") || m.contains("_estimate") || m.contains("_send") || m == "eth_chainId" || m == "eth_blockNumber" || m == "eth_syncing"; + if looks_like_method && !SAFE_ON_EMPTY.contains(&m) && !GATED.contains(&m) { + unclassified.push(format!("{name}:{}: {m}", i + 1)); + } + } + } + } + assert!(offenders.is_empty(), "exec JSON-RPC built outside execrpc.rs: {offenders:?}"); + assert!(unclassified.is_empty(), "exec methods not classified in execrpc.rs: {unclassified:?}"); + } +} diff --git a/app/igneum-app/src/extnode.rs b/app/igneum-app/src/extnode.rs index 25764ac1..91aee55b 100644 --- a/app/igneum-app/src/extnode.rs +++ b/app/igneum-app/src/extnode.rs @@ -8,7 +8,6 @@ //! too, read every 30 s instead of parsed from a stdout the app does not own. use serde_json::{json, Value}; use std::path::Path; -use std::process::Command; use std::time::Duration; /// What the other node answered: None where it could not answer. @@ -110,14 +109,8 @@ pub fn step(port_open: bool, now_s: f64, gone_since: &mut Option) -> Step { /// One JSON-RPC call to the node's EVM port through curl (the engine carries no HTTP client; update.rs does the same). pub fn rpc(evm_port: u16, method: &str, params: Value, limit: Duration) -> Option { - let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string(); - let out = crate::detect::run_timeout( - Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", &format!("{}", limit.as_secs().max(1)), "-X", "POST", &format!("http://127.0.0.1:{evm_port}"), "-H", "Content-Type: application/json", "-d", &body]), - None, - limit + Duration::from_secs(2), - )?; - let v: Value = serde_json::from_str(&out).ok()?; - v.get("result").cloned().filter(|r| !r.is_null()) + // one path (ledger N7): execrpc holds a records-indexing method until the node's exec follower has a record + crate::execrpc::call(evm_port, method, params, limit).ok().filter(|r| !r.is_null()) } /// The other node's answers, with what each method gives: eth_chainId (every node), igneum_getNodeInfo (newer nodes). diff --git a/app/igneum-app/src/main.rs b/app/igneum-app/src/main.rs index f2a40b61..d513fc97 100644 --- a/app/igneum-app/src/main.rs +++ b/app/igneum-app/src/main.rs @@ -25,6 +25,7 @@ mod state; mod manifest; mod ota; mod update; +mod execrpc; mod jobs; mod jobrun; mod jobbuild; diff --git a/app/igneum-app/src/prover.rs b/app/igneum-app/src/prover.rs index ed888197..9ee56fce 100644 --- a/app/igneum-app/src/prover.rs +++ b/app/igneum-app/src/prover.rs @@ -168,22 +168,8 @@ fn exec_boundary(shared: &Shared) -> u64 { } fn evm_rpc(shared: &Shared, method: &str, params: Value, timeout: Duration) -> Result { - let body = json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string(); - let tmp = std::env::temp_dir().join(format!("igneum-prover-{}-{}.json", std::process::id(), method)); - std::fs::write(&tmp, body).map_err(|e| e.to_string())?; - 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)) + // one path (ledger N7): execrpc holds a records-indexing method until the node's exec follower has a record + crate::execrpc::call(shared.runtime.evm_port(), method, params, timeout) } fn find_tools(bin_dir: &Path) -> Result { diff --git a/app/igneum-app/src/update.rs b/app/igneum-app/src/update.rs index 38282353..2ac67c8f 100644 --- a/app/igneum-app/src/update.rs +++ b/app/igneum-app/src/update.rs @@ -43,41 +43,20 @@ fn days_from_civil(y: i64, m: i64, d: i64) -> i64 { /// True once the node's exec follower holds a record (igneum_getExecStatus's executedTipHash is set). Any error or an /// unreachable node reads false: the block sample waits rather than asks. pub fn exec_has_record(evm_port: u16) -> bool { - let body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"igneum_getExecStatus\",\"params\":[]}"; - let out = crate::detect::run_timeout( - Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "5", "-X", "POST", &format!("http://127.0.0.1:{evm_port}"), "-H", "Content-Type: application/json", "-d", body]), - None, - Duration::from_secs(7), - ); - out.as_deref().map(exec_status_has_record).unwrap_or(false) + crate::execrpc::has_record(evm_port) } /// The reading of an igneum_getExecStatus reply: a record is held when executedTipHash is a non-null string. pub fn exec_status_has_record(reply: &str) -> bool { - serde_json::from_str::(reply) - .ok() - .and_then(|v| v.get("result")?.get("executedTipHash")?.as_str().map(|h| !h.is_empty())) - .unwrap_or(false) + serde_json::from_str::(reply).ok().map(|v| crate::execrpc::status_has_record(v.get("result").unwrap_or(&serde_json::Value::Null))).unwrap_or(false) } /// The latest block's timestamp (unix seconds) from the node's Ethereum JSON-RPC (the execution layer mirrors the /// consensus block times). The first clock source: local time against what the peers produced. pub fn latest_block_time(evm_port: u16) -> Option { - // 0.3.18 (ledger N7, 7 October 2026): a node whose exec follower holds no record yet dies on eth_getBlockByNumber - // (rpc.rs indexes records[0] on an empty vector and the panic hook exits the process). The nodes before 0.3.18's - // fix are live on every machine, and this sample runs every 9 s once blocks arrive, so it asks igneum_getExecStatus - // first (it indexes nothing) and takes no block until the follower reports an executed tip. - if !exec_has_record(evm_port) { - return None; - } - let body = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getBlockByNumber\",\"params\":[\"latest\",false]}"; - let out = crate::detect::run_timeout( - Command::new(crate::platform::tool("curl")).args(["-s", "--max-time", "5", "-X", "POST", &format!("http://127.0.0.1:{evm_port}"), "-H", "Content-Type: application/json", "-d", body]), - None, - Duration::from_secs(7), - )?; - let v: serde_json::Value = serde_json::from_str(&out).ok()?; - let ts = v.get("result")?.get("timestamp")?.as_str()?; + // 0.3.18/0.3.19 (ledger N7): through the one gate, which asks nothing of a follower without a record + let block = crate::execrpc::call(evm_port, "eth_getBlockByNumber", serde_json::json!(["latest", false]), Duration::from_secs(5)).ok()?; + let ts = block.get("timestamp")?.as_str()?; u64::from_str_radix(ts.trim_start_matches("0x"), 16).ok().map(|t| t as f64) }