igneum/app/igneum-app/src/execrpc.rs
igneum-labs d68c82d78a execrpc: 0.3.21's two callers classified (igneum_getRecentBlocks gated, eth_getBalance safe)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 13:12:26 +00:00

164 lines
9.4 KiB
Rust

//! 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",
// the engine's balance read (0.3.21): an account lookup at the latest state, nothing indexed by block number
"eth_getBalance",
];
/// 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",
// 0.3.21's live page reads recent blocks by number through the same path (src/live.rs); held like the rest
"igneum_getRecentBlocks",
];
/// One JSON-RPC POST to 127.0.0.1:<evm_port> 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<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-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"))?;
// an empty or unparsable body is no answer (a stopped node's socket still accepts the connection and curl
// returns nothing inside its own limit; the probe must read that as silence, 7 October 2026)
let v: Value = serde_json::from_str(&out).map_err(|e| format!("{method}: the node's RPC did not answer ({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 engine's readiness probe (every 5 s): did the node's RPC answer at all, and does the follower hold a record.
/// The first is the node watchdog's sign of life; the second, with `synced`, is the workers' start gate.
pub fn probe(evm_port: u16) -> (bool, bool) {
match post(evm_port, "igneum_getExecStatus", json!([]), Duration::from_secs(5)) {
Ok(r) => (true, status_has_record(&r)),
Err(e) => (!e.contains("did not answer"), 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<Value, String> {
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<String> {
// 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:?}");
}
}