From a4192a170a061f8182913815339646b36b5b3782 Mon Sep 17 00:00:00 2001 From: ManuelArto Date: Wed, 11 Nov 2026 11:50:11 +0100 Subject: [PATCH 1/2] feat(ffi): sync src/ffi with Nethermind 1e5d1fa8a0 for native ABI 10 Nethermind feature/eip8288-lean now requires ABI 10 (NethermindEth/nethermind@e1939b5da3 adds nlean_last_error), so the ABI 9 library this repo publishes fails the startup check of every EIP-8288 node. Apply the tools/lean-ffi changes from 39e7bff6ef to 1e5d1fa8a0, keeping this repo's MIT headers and paths, and move the smoke test, the package description and NETHERMIND_REVISION to ABI 10. The committed binaries still need a Build Lean run. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test-publish.yml | 2 +- .../NativeLibraryTests.cs | 2 +- .../Nethermind.Crypto.Lean.csproj | 2 +- src/ffi/Cargo.lock | 2 + src/ffi/Cargo.toml | 4 + src/ffi/README.md | 25 +- src/ffi/examples/lean_startup_stub.rs | 4 +- src/ffi/src/lib.rs | 342 +++++++++++++++++- 8 files changed, 352 insertions(+), 31 deletions(-) diff --git a/.github/workflows/test-publish.yml b/.github/workflows/test-publish.yml index b85b484..a4c56ee 100644 --- a/.github/workflows/test-publish.yml +++ b/.github/workflows/test-publish.yml @@ -120,7 +120,7 @@ jobs: env: # The NethermindEth/nethermind commit whose native suite runs against the library. Bump it with any ABI or # verification key change. - NETHERMIND_REVISION: 0260e692fcf8e6b8bcb41289d877e0d5e380b5cd + NETHERMIND_REVISION: 1e5d1fa8a071b0b329a243437d00026e1d4a72df NATIVE_TEST_PROJECT: Nethermind.Crypto.LeanFfi.Test LEANVM_NUM_THREADS: 1 steps: diff --git a/src/bindings/Nethermind.Crypto.Lean.Tests/NativeLibraryTests.cs b/src/bindings/Nethermind.Crypto.Lean.Tests/NativeLibraryTests.cs index 722f60e..bc314d3 100644 --- a/src/bindings/Nethermind.Crypto.Lean.Tests/NativeLibraryTests.cs +++ b/src/bindings/Nethermind.Crypto.Lean.Tests/NativeLibraryTests.cs @@ -12,7 +12,7 @@ namespace Nethermind.Crypto.Lean.Tests; public partial class NativeLibraryTests { private const string Library = "nethermind_lean"; - private const uint ExpectedAbi = 9; + private const uint ExpectedAbi = 10; private const string ExpectedAggregatedVk = "417152e8bd83e0dfb6f3e2bb8991e948f62c7f15d74b693b55979a40b239efa1"; // A project reference leaves the library under runtimes//native; a package consumer gets it beside the binary. diff --git a/src/bindings/Nethermind.Crypto.Lean/Nethermind.Crypto.Lean.csproj b/src/bindings/Nethermind.Crypto.Lean/Nethermind.Crypto.Lean.csproj index f66d7cf..6e6e687 100644 --- a/src/bindings/Nethermind.Crypto.Lean/Nethermind.Crypto.Lean.csproj +++ b/src/bindings/Nethermind.Crypto.Lean/Nethermind.Crypto.Lean.csproj @@ -8,7 +8,7 @@ - Native EIP-8288 Lean backend (libnethermind_lean, ABI 9) for linux-x64 (x86-64-v3 + PCLMULQDQ) and linux-arm64 (AES/PMULL). + Native EIP-8288 Lean backend (libnethermind_lean, ABI 10) for linux-x64 (x86-64-v3 + PCLMULQDQ) and linux-arm64 (AES/PMULL). Nethermind.Crypto.Lean MIT https://github.com/nethermindeth/lean-bindings diff --git a/src/ffi/Cargo.lock b/src/ffi/Cargo.lock index 7be8550..44c062c 100644 --- a/src/ffi/Cargo.lock +++ b/src/ffi/Cargo.lock @@ -1376,11 +1376,13 @@ dependencies = [ "bincode", "lean_compiler", "lean_vm", + "libc", "parallel", "primitives", "rec_aggregation", "sphincs", "tiny-keccak", + "zk_alloc", ] [[package]] diff --git a/src/ffi/Cargo.toml b/src/ffi/Cargo.toml index 1a7ffbd..1154c27 100644 --- a/src/ffi/Cargo.toml +++ b/src/ffi/Cargo.toml @@ -19,9 +19,13 @@ lean_vm = { git = "https://github.com/Marchhill/leanVM", rev = "e2bc4212013087c0 lean_compiler = { git = "https://github.com/Marchhill/leanVM", rev = "e2bc4212013087c0823188e975b224ade286d878" } primitives = { git = "https://github.com/Marchhill/leanVM", rev = "e2bc4212013087c0823188e975b224ade286d878" } parallel = { git = "https://github.com/Marchhill/leanVM", rev = "e2bc4212013087c0823188e975b224ade286d878" } +zk_alloc = { git = "https://github.com/Marchhill/leanVM", rev = "e2bc4212013087c0823188e975b224ade286d878" } bincode = "1.3" tiny-keccak = { version = "2.0", features = ["keccak"] } +[target.'cfg(all(target_os = "linux", target_env = "gnu"))'.dependencies] +libc = "0.2" + [profile.release] opt-level = 3 diff --git a/src/ffi/README.md b/src/ffi/README.md index 8189c97..01c4707 100644 --- a/src/ffi/README.md +++ b/src/ffi/README.md @@ -27,6 +27,12 @@ with an illegal instruction (SIGILL) on an older CPU: build on the oldest CPU th set `RUSTFLAGS` to that CPU plus the carry-less multiply feature (for example `-C target-cpu=x86-64-v3 -C target-feature=+pclmulqdq`). Proving uses leanVM's arena allocator, which recycles the prover's transient buffers across proofs; it allows one proof per process at a time, which the backend's prover lock ensures. +No arena phase opens while a verification runs; a verification waits only for an opening in progress (including the prover's input preparation, which verifies any leanSTARK inputs), then runs +alongside the proof node on its own slab rather than for the node (1–3 s on a 12-core arm64 host, about 7 s on 2 cores). +The arena keeps its touched slabs resident between proofs. leanVM also switches glibc's heap trimming and +large-allocation mmap off process-wide when it enables the arena; at the first proof the backend restores the +operator's `GLIBC_TUNABLES`/`MALLOC_*_` values or glibc's defaults, so the host's own freed memory still returns to +the kernel. glibc's adaptive mmap threshold stays off. `BuildLeanFfi=true` builds and copies the host native library into consumer build/publish outputs and rejects a different target RID. Build on each target platform. The release Docker @@ -43,12 +49,13 @@ The main solution compiles the test project without requiring Rust. Without the the native suite is explicitly skipped. With it, the backend and fixtures are required and missing libraries fail the run. -The C ABI is version 9, with Cdecl calls, 32-byte hashes/keys and `size_t` buffer lengths. +The C ABI is version 10, with Cdecl calls, 32-byte hashes/keys and `size_t` buffer lengths. `nlean_limits` writes nine u32 values (proof bytes, dependencies, recursive children, SPHINCS witness bytes, leanSTARK claims per list, instructions, operand offset, aggregation input bytes, mixed guest proof bytes); startup checks these against the managed protocol bounds before accepting work. -Verification/proving return 1 on success, 0 on failure and -1 for a contained panic. Callers initialize proof output +Verification/proving return 1 on success, 0 on failure and -1 for a contained panic. After -1, `nlean_last_error(out, len)` on the same +thread copies up to `len` bytes of the panic message and returns its full length. Callers initialize proof output pointers to null; successful proof allocations must be released once with `nlean_free` using the returned pointer and length. Verification contains upstream panics and never accepts malformed inputs. Panic diagnostics on ABI callers and the pinned parallel worker @@ -137,7 +144,7 @@ EIP, with the pinned guest's generic leanVM CPU proof as its leanSTARK profile: statement over it to the prover, a received mode-1 wrapper over it is invalid and an inclusion-list proof over it imposes no obligation, both without penalty or verification. -Startup checks ABI 9, the pinned key and the nine bounds together. New devnets require fresh +Startup checks ABI 10, the pinned key and the nine bounds together. New devnets require fresh chain and database namespaces. The latest companion specification has no block dependency quota: blocks are bounded by gas, mode-0 wrappers by `MAX_LEANSIG_DEPS_PER_WRAPPER`, `MAX_LEANSTARK_DEPS_PER_WRAPPER` and `MAX_WRAPPER_BYTES`, and mode-1 wrappers by `MAX_WRAPPER_BYTES` and the dependency-list limit of the circuit behind `AGGREGATED_VK`, which EIP-8288 fixes from benchmarks before activation. This prototype guest's limit is 256 dependencies, at most 16 of them leanSTARK; a received mode-1 wrapper over it is invalid and discarded before verification, without penalizing its sender. Blocks have no such limit in the draft, so this key cannot prove every block the draft permits and this prototype is not full-capacity conformance. Raising the bound changes the guest and so `AGGREGATED_VK`. Local inability to prove or retain a set is not an inclusion-list omission excuse. Full activation requires a verifier and proof format supporting every set the fork's gas rules permit, with the complete construction, including recursive merges and discards, benchmarked against the slot deadline. @@ -183,9 +190,11 @@ with `UnsupportedFork` rather than losing their proofs. Enable EIP-8141 and EIP-8288 in a development chainspec with `eip8288TransitionTimestamp`, or the geth genesis key `eip8288PrototypeTime`. The named `Eip8288Prototype` / `eip8288PrototypeTime` also enables master's EIP-8250 keyed -nonces, EIP-8272 recent roots and EIP-7906 POST_TX frames. Frame transactions use explicit -`nonce_keys`; `[0]` retains account-nonce behavior. Individual chainspec transition fields -remain independently configurable. Recent roots follow master's older envelope-reference / +nonces and EIP-7906 POST_TX frames. It does not enable slot numbers or EIP-8272 recent roots: EIP-8272 requires +EIP-7843, so schedule it with `eip8272PrototypeTime` or `eip8272TransitionTimestamp` at or after EIP-7843 (for example +Amsterdam's `amsterdamTime`); loading refuses an EIP-8272 activation without or before EIP-7843, and an EIP-7843 later +than EIP-8288. Frame transactions use explicit `nonce_keys`; `[0]` retains account-nonce behavior. Other chainspec +transition fields remain independently configurable. Recent roots follow master's older envelope-reference / `RECENTROOTREFLOAD` draft, with existing commitment and slot-age checks; the newer EIP-8272 canonical VERIFY contract remains TBD and is not implemented here. @@ -324,7 +333,9 @@ circuit's dependency-list limit (the guest's 256 dependencies), and a bucket als proving cost equal to folding 16 fresh signatures: already proven aggregates merge whole and may carry many more dependencies. Mode-0 witnesses fold directly into the aggregate, and a set a held proof already proves is not proven again; for partial coverage held proofs are merged and only -missing dependencies added, discarding those outside the retained transactions. A set an announced +missing dependencies added, discarding those outside the retained transactions. A dependency without a +held direct witness takes the held recursive proof covering the most still-needed dependencies, as each +recursive input costs about one recursive verification whatever its size. A set an announced mode-1 wrapper claims (by its `deps-hash`) is left for up to two intervals while that wrapper is fetched, then proven locally. Completed selections are checked against current pool membership before publication. `eth_getProofWrapper` returns a copy without invoking the prover. diff --git a/src/ffi/examples/lean_startup_stub.rs b/src/ffi/examples/lean_startup_stub.rs index dfdfacb..a6be176 100644 --- a/src/ffi/examples/lean_startup_stub.rs +++ b/src/ffi/examples/lean_startup_stub.rs @@ -5,9 +5,9 @@ #[unsafe(no_mangle)] pub extern "C" fn nlean_abi_version() -> u32 { if std::env::var("NETHERMIND_LEAN_STARTUP_PROBE").as_deref() == Ok("abi") { - 8 - } else { 9 + } else { + 10 } } diff --git a/src/ffi/src/lib.rs b/src/ffi/src/lib.rs index 199b25d..5a6ca66 100644 --- a/src/ffi/src/lib.rs +++ b/src/ffi/src/lib.rs @@ -8,10 +8,10 @@ use lean_vm::cpu::{self, Program}; use primitives::field::F192; use rec_aggregation::eip8288_mixed::{self, GenericInput, MixedProof}; use sphincs::{SphincsPublicKey, SphincsSignature}; -use std::cell::Cell; +use std::cell::{Cell, RefCell}; use std::collections::{BTreeSet, HashMap, HashSet}; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Barrier, Mutex, OnceLock}; +use std::sync::{Barrier, Condvar, Mutex, OnceLock}; use std::thread::ThreadId; use tiny_keccak::{Hasher, Keccak}; @@ -31,10 +31,168 @@ fn begin_proof() -> std::sync::MutexGuard<'static, ()> { let guard = PROVER .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - lean_vm::init_prover(); + static ARENA: std::sync::Once = std::sync::Once::new(); + ARENA.call_once(|| { + lean_vm::init_prover(); + restore_system_heap(); + }); guard } +/// Undoes the process-wide glibc tuning `init_prover` applies, restoring the operator's setting or glibc's default. +/// +/// The arena is its own mapping and does not rely on that tuning, while the host would otherwise never trim its heap or +/// mmap large allocations again. glibc's adaptive mmap threshold stays off: `init_prover` already disabled it, and no +/// mallopt re-enables it. +#[cfg(all(target_os = "linux", target_env = "gnu"))] +fn restore_system_heap() { + let trim = glibc_setting("trim_threshold", "MALLOC_TRIM_THRESHOLD_", 128 * 1024); + let mmap_max = glibc_setting("mmap_max", "MALLOC_MMAP_MAX_", 65536); + // SAFETY: mallopt only adjusts allocator tuning parameters. + unsafe { + libc::mallopt(libc::M_TRIM_THRESHOLD, trim); + libc::mallopt(libc::M_MMAP_MAX, mmap_max); + } +} + +/// The `glibc.malloc.` value from `GLIBC_TUNABLES`, else the legacy environment alias, else `default`. +#[cfg(all(target_os = "linux", target_env = "gnu"))] +fn glibc_setting(tunable: &str, alias: &str, default: i32) -> i32 { + let key = format!("glibc.malloc.{tunable}="); + std::env::var("GLIBC_TUNABLES") + .ok() + .and_then(|tunables| { + tunables + .split(':') + .find_map(|entry| parse_c_integer(entry.strip_prefix(key.as_str())?)) + }) + .or_else(|| parse_c_integer(&std::env::var(alias).ok()?)) + .unwrap_or(default) +} + +/// Parses an unsigned integer as `strtoul(s, _, 0)` does (decimal, `0x` hex or leading-`0` octal), saturating at `i32::MAX`. +#[cfg(all(target_os = "linux", target_env = "gnu"))] +fn parse_c_integer(value: &str) -> Option { + let parsed = if let Some(hex) = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + { + u64::from_str_radix(hex, 16) + } else if value.len() > 1 && value.starts_with('0') { + u64::from_str_radix(&value[1..], 8) + } else { + value.parse() + }; + parsed.ok().map(|v| i32::try_from(v).unwrap_or(i32::MAX)) +} +#[cfg(not(all(target_os = "linux", target_env = "gnu")))] +fn restore_system_heap() {} + +/// Keeps verifications off arena phase openings. A thread's `ArenaVec`s die when it next allocates after a phase opens, +/// and an allocation racing the opening can land in the stale phase, so no phase may open while a verification runs. +/// Verifications may run inside an open phase, on their own slabs, so they wait only for an opening, not for the proof +/// node: a node takes several seconds on a few cores, which held up block import and wrapper admission. +struct PhaseGate { + state: Mutex, + changed: Condvar, +} + +struct GateState { + verifications: usize, + /// The arena generation an opening started from, while one is pending or its node is still proving. + opening_from: Option, +} + +static PHASE_GATE: PhaseGate = PhaseGate { + state: Mutex::new(GateState { + verifications: 0, + opening_from: None, + }), + changed: Condvar::new(), +}; + +/// How often a verification held at an opening checks whether the phase has opened, which nothing signals. +const OPENING_POLL: std::time::Duration = std::time::Duration::from_millis(1); + +impl PhaseGate { + fn lock(&self) -> std::sync::MutexGuard<'_, GateState> { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } +} + +struct OpeningGuard; + +impl Drop for OpeningGuard { + fn drop(&mut self) { + PHASE_GATE.lock().opening_from = None; + PHASE_GATE.changed.notify_all(); + } +} + +/// Runs `prove`, which must open at most one arena phase, once no verification is in flight. +fn in_arena_phase(prove: impl FnOnce() -> T) -> T { + let mut state = PHASE_GATE.lock(); + let generation = zk_alloc::stats().phases; + state.opening_from = Some(generation); + while state.verifications > 0 { + state = PHASE_GATE + .changed + .wait(state) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + drop(state); + let _opening = OpeningGuard; + let result = prove(); + assert!( + zk_alloc::stats().phases - generation <= 1, + "a gated prove opened more than one arena phase" + ); + result +} + +/// Runs `verify` outside any arena phase opening: before it, or once the phase it waits for has opened. +fn verify_between_phases(verify: impl FnOnce() -> T) -> T { + let mut state = PHASE_GATE.lock(); + while let Some(generation) = state.opening_from { + if zk_alloc::stats().phases != generation { + break; + } + state = PHASE_GATE + .changed + .wait_timeout(state, OPENING_POLL) + .unwrap_or_else(std::sync::PoisonError::into_inner) + .0; + } + state.verifications += 1; + drop(state); + let _verification = VerificationGuard; + verify() +} + +struct VerificationGuard; + +impl Drop for VerificationGuard { + fn drop(&mut self) { + let mut state = PHASE_GATE.lock(); + state.verifications -= 1; + if state.verifications == 0 { + PHASE_GATE.changed.notify_all(); + } + } +} + +fn aggregate_mixed( + children: &[MixedProof], + signatures: &[(SphincsPublicKey, [u8; 32], SphincsSignature)], + generic: &[GenericInput], + deps: &[Dep], +) -> Result { + in_arena_phase(|| eip8288_mixed::aggregate_mixed(children, signatures, generic, deps, 1)) + .map_err(|_| ()) +} + // leanVM's carry-less-multiply field arithmetic is about 68x slower without these features. An // environment RUSTFLAGS replaces .cargo/config.toml's target-cpu=native instead of adding to it, so // refuse to build a backend that silently lost them. @@ -49,6 +207,7 @@ compile_error!( static ACTIVE_ABI_CALLS: AtomicUsize = AtomicUsize::new(0); static ABI_WORKERS: OnceLock> = OnceLock::new(); thread_local! { static IN_ABI: Cell = const { Cell::new(false) }; } +thread_local! { static LAST_PANIC: RefCell = const { RefCell::new(String::new()) }; } type Dep = [u8; 96]; pub fn keccak(bytes: &[u8]) -> [u8; 32] { @@ -290,13 +449,19 @@ fn encode_program(program: &Program) -> Vec { } out } -fn stark_program(code: &[u8]) -> Result { - use cpu::{DerefMode, Op}; +/// The instruction count of `code` if its length matches it, checked without decoding any instruction. +fn instruction_count(code: &[u8]) -> Result { let mut r = Reader { bytes: code }; let n = r.number()?; if n == 0 || n > MAX_INSTRUCTIONS || !n.is_power_of_two() || r.bytes.len() != n * 45 { return Err(()); } + Ok(n) +} +fn stark_program(code: &[u8]) -> Result { + use cpu::{DerefMode, Op}; + let n = instruction_count(code)?; + let mut r = Reader { bytes: &code[4..] }; let mut ops = Vec::with_capacity(n); for _ in 0..n { let tag = r.take(1)?[0]; @@ -399,6 +564,7 @@ fn decode_stark( } let mut r = Reader { bytes: witness }; let code = r.blob()?; + instruction_count(code)?; if keccak(&[&eip8288_mixed::GENERIC_VK_DOMAIN[..], code].concat()) != *vk { return Err(()); } @@ -415,7 +581,7 @@ fn decode_stark( } fn verify_stark(hash: &[u8; 32], vk: &[u8; 32], witness: &[u8]) -> bool { - decode_stark(hash, vk, witness).is_ok() + verify_between_phases(|| decode_stark(hash, vk, witness).is_ok()) } // EIP-8288 `stark_proof`: the guest proof alone, checked against `deps_hash` and the pinned key. @@ -430,7 +596,7 @@ fn preflight_proof(proof: &[u8]) -> Result<(), ()> { fn verify_aggregate(hash: &[u8; 32], proof: &[u8]) -> Result<(), ()> { preflight_proof(proof)?; - eip8288_mixed::verify_mixed_deps(hash, proof).map_err(|_| ()) + verify_between_phases(|| eip8288_mixed::verify_mixed_deps(hash, proof).map_err(|_| ())) } // A recursive child is proven against its declared dependencies, which the caller supplies; @@ -491,8 +657,7 @@ fn aggregate_bounded( .collect::>() .into_iter() .collect(); - nodes - .push(eip8288_mixed::aggregate_mixed(&[], &signatures, &[], &deps, 1).map_err(|_| ())?); + nodes.push(aggregate_mixed(&[], &signatures, &[], &deps)?); } for leaf in generic.chunks(RAW_GENERICS_PER_LEAF) { let inputs: Vec<_> = leaf @@ -509,7 +674,7 @@ fn aggregate_bounded( .collect::>() .into_iter() .collect(); - nodes.push(eip8288_mixed::aggregate_mixed(&[], &[], &inputs, &deps, 1).map_err(|_| ())?); + nodes.push(aggregate_mixed(&[], &[], &inputs, &deps)?); } while nodes.len() > 1 { let mut next = Vec::new(); @@ -527,9 +692,7 @@ fn aggregate_bounded( if batch.len() == 1 && batch[0].dependencies() == deps { next.push(batch.pop().ok_or(())?); } else { - next.push( - eip8288_mixed::aggregate_mixed(&batch, &[], &[], &deps, 1).map_err(|_| ())?, - ); + next.push(aggregate_mixed(&batch, &[], &[], &deps)?); } } nodes = next; @@ -538,7 +701,7 @@ fn aggregate_bounded( if node.dependencies() == desired { return Ok(node); } - eip8288_mixed::aggregate_mixed(&[node], &[], &[], desired, 1).map_err(|_| ()) + aggregate_mixed(&[node], &[], &[], desired) } pub fn prove_aggregate(hash: &[u8; 32], input: &[u8]) -> Result, ()> { @@ -631,7 +794,7 @@ unsafe fn bytes_with_limit<'a>(ptr: *const u8, len: usize, limit: usize) -> Resu } } /// Runs one ABI call: returns 1 for accepted, 0 for rejected input and -1 for a contained panic, -/// so callers never read a fault as a verdict. Every `checked` export shares this contract, including +/// so callers never read a fault as a verdict. The panic message stays readable through `nlean_last_error`. Every `checked` export shares this contract, including /// `nlean_aggregated_vk` and `nlean_prove_recursive`, where the C# side accepts only `== 1` as success. fn checked(f: impl FnOnce() -> Result) -> i32 { static HOOK: OnceLock<()> = OnceLock::new(); @@ -644,6 +807,7 @@ fn checked(f: impl FnOnce() -> Result) -> i32 { } let _guard = CallGuard(IN_ABI.with(|flag| flag.replace(true))); ACTIVE_ABI_CALLS.fetch_add(1, Ordering::AcqRel); + LAST_PANIC.with(|message| message.borrow_mut().clear()); std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { HOOK.get_or_init(|| { let previous = std::panic::take_hook(); @@ -674,11 +838,39 @@ fn checked(f: impl FnOnce() -> Result) -> i32 { }); f() })) - .map_or(-1, |verdict| verdict.unwrap_or(false) as i32) + .map_or_else( + |payload| { + let message = payload + .downcast_ref::<&str>() + .map(|text| text.to_string()) + .or_else(|| payload.downcast_ref::().cloned()) + .unwrap_or_else(|| "non-string panic payload".to_string()); + LAST_PANIC.with(|last| *last.borrow_mut() = message); + -1 + }, + |verdict| verdict.unwrap_or(false) as i32, + ) } #[unsafe(no_mangle)] pub extern "C" fn nlean_abi_version() -> u32 { - 9 + 10 +} +/// Copies up to `len` bytes of the calling thread's last contained panic message into `out` and returns its full +/// length; empty unless that thread's latest `checked` call returned -1. +/// +/// # Safety +/// `out` must reference `len` writable bytes, or be null with `len` 0. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn nlean_last_error(out: *mut u8, len: usize) -> usize { + LAST_PANIC.with(|message| { + let message = message.borrow(); + let copied = message.len().min(len); + if !out.is_null() { + // SAFETY: copied <= len, and the caller guarantees `out` holds len writable bytes. + unsafe { std::ptr::copy_nonoverlapping(message.as_ptr(), out, copied) }; + } + message.len() + }) } /// # Safety /// Output must hold nine writable u32 values. @@ -817,7 +1009,7 @@ pub fn prove_stark(source: &str, hash: &[u8; 32]) -> Result<(Vec, [u8; 32]), let program = lean_compiler::compile(&lean_compiler::parse(source).map_err(|_| ())?); let key = eip8288_mixed::generic_verification_key(&program).map_err(|_| ())?; let code = encode_program(&program); - let (proof, _) = cpu::prove(&program, pi(hash), 1).map_err(|_| ())?; + let (proof, _) = in_arena_phase(|| cpu::prove(&program, pi(hash), 1)).map_err(|_| ())?; if !eip8288_mixed::generic_recursion_supported(&program, &proof) { return Err(()); } @@ -1222,7 +1414,7 @@ mod tests { } #[test] fn exported_limits_match_and_panics_fail_closed() { - assert_eq!(nlean_abi_version(), 9); + assert_eq!(nlean_abi_version(), 10); let mut key = [0u8; 32]; assert_eq!(unsafe { nlean_aggregated_vk(key.as_mut_ptr()) }, 1); assert_eq!( @@ -1249,8 +1441,21 @@ mod tests { ); assert_eq!(unsafe { nlean_limits(std::ptr::null_mut(), 9) }, 0); assert_eq!(unsafe { nlean_limits(limits.as_mut_ptr(), 8) }, 0); + let last_error = || { + let mut message = [0u8; 64]; + let len = unsafe { nlean_last_error(message.as_mut_ptr(), message.len()) }; + String::from_utf8(message[..len].to_vec()).unwrap() + }; assert_eq!(checked(|| panic!("contained upstream failure")), -1); + assert_eq!(last_error(), "contained upstream failure"); + let pool_panic = || { + parallel::for_each(parallel::num_threads(), |_| panic!("pool task failed")); + Ok(true) + }; + assert_eq!(checked(pool_panic), -1); + assert_eq!(last_error(), "pool task failed"); assert_eq!(checked(|| Err(())), 0); + assert_eq!(last_error(), ""); } #[test] @@ -1462,6 +1667,105 @@ mod tests { } } + fn single_signature_input(seed: u8) -> ([u8; 32], Vec) { + let (sk, pk) = sphincs::key_gen_from_seed([seed; 32]); + let message = [seed; 32]; + let signature = [ + pk.flatten().as_slice(), + sphincs::sign(&sk, &message).to_bytes().as_slice(), + ] + .concat(); + let dep = sphincs_dependency(&pk, &message); + let mut input = Vec::new(); + put_number(&mut input, 1); + input.extend_from_slice(&dep); + put_blob(&mut input, &signature); + put_number(&mut input, 0); + put_deps(&mut input, &[]); + (commitment(&[dep]), input) + } + + #[test] + fn verification_and_proving_do_not_share_arena_phases() { + let (hash, input) = single_signature_input(44); + let proof = prove_aggregate(&hash, &input).unwrap(); + let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let verified = std::sync::Arc::new(AtomicUsize::new(0)); + let verifier = { + let (stop, verified, proof) = (stop.clone(), verified.clone(), proof.clone()); + std::thread::spawn(move || { + let mut failures = 0; + while !stop.load(Ordering::Acquire) { + failures += usize::from(verify_aggregate(&hash, &proof).is_err()); + verified.fetch_add(1, Ordering::Release); + } + failures + }) + }; + // Phase churn as a prover produces it, then a real proof whose arena buffers the verifier must not touch. + while verified.load(Ordering::Acquire) < 20 { + let _guard = begin_proof(); + for _ in 0..1000 { + in_arena_phase(|| drop(zk_alloc::enter_phase())); + } + } + let (other_hash, other_input) = single_signature_input(45); + let other = prove_aggregate(&other_hash, &other_input); + stop.store(true, Ordering::Release); + assert_eq!(verifier.join().unwrap(), 0); + verify_aggregate(&other_hash, &other.unwrap()).unwrap(); + } + + #[test] + fn verification_does_not_wait_for_an_open_phase() { + const PHASE: std::time::Duration = std::time::Duration::from_secs(4); + let (hash, input) = single_signature_input(46); + let proof = prove_aggregate(&hash, &input).unwrap(); + let opened = std::sync::Arc::new(Barrier::new(2)); + let prover = { + let opened = opened.clone(); + std::thread::spawn(move || { + let _guard = begin_proof(); + in_arena_phase(|| { + let _phase = zk_alloc::enter_phase(); + opened.wait(); + std::thread::sleep(PHASE); + }); + }) + }; + opened.wait(); + let started = std::time::Instant::now(); + verify_aggregate(&hash, &proof).unwrap(); + let waited = started.elapsed(); + prover.join().unwrap(); + assert!( + waited < PHASE / 2, + "verification waited {waited:?} for the open phase" + ); + } + + #[cfg(all(target_os = "linux", target_env = "gnu"))] + #[test] + fn glibc_settings_parse_like_strtoul() { + assert_eq!(parse_c_integer("131072"), Some(131072)); + assert_eq!(parse_c_integer("0x40000"), Some(0x40000)); + assert_eq!(parse_c_integer("0100"), Some(64)); + assert_eq!(parse_c_integer("0"), Some(0)); + assert_eq!(parse_c_integer("4294967296"), Some(i32::MAX)); + assert_eq!(parse_c_integer("lots"), None); + } + + #[cfg(all(target_os = "linux", target_env = "gnu"))] + #[test] + fn proving_leaves_host_heap_on_glibc_defaults() { + drop(begin_proof()); + // Above glibc's 32 MiB dynamic mmap threshold cap, below a thread arena's 64 MiB heap: only M_MMAP_MAX = 0 keeps it off mmap. + let before = unsafe { libc::mallinfo2() }.hblks; + let buffer = std::hint::black_box(vec![1u8; 40 << 20]); + assert!(unsafe { libc::mallinfo2() }.hblks > before); + drop(buffer); + } + #[test] fn quiet_hook_preserves_unrelated_threads() { const CHILD: &str = "NLEAN_QUIET_HOOK_TEST"; From 93eb396d888a19d396fb53c05d547f109aa597fc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Nov 2026 11:00:38 +0000 Subject: [PATCH 2/2] chore(deps): update Lean binaries to 0.1.0 (a4192a170a) --- .../runtimes/linux-arm64/native/libnethermind_lean.so | 4 ++-- .../runtimes/linux-x64/native/libnethermind_lean.so | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/bindings/Nethermind.Crypto.Lean/runtimes/linux-arm64/native/libnethermind_lean.so b/src/bindings/Nethermind.Crypto.Lean/runtimes/linux-arm64/native/libnethermind_lean.so index 1f3347f..9f00254 100644 --- a/src/bindings/Nethermind.Crypto.Lean/runtimes/linux-arm64/native/libnethermind_lean.so +++ b/src/bindings/Nethermind.Crypto.Lean/runtimes/linux-arm64/native/libnethermind_lean.so @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:101d357cc6cf81912b3063f9024ac7e07d9d13f169e732a9f4e765809d58471e -size 2693920 +oid sha256:d46f93958f6300c52065a9a16a189c4892d30399c5ddb18f34e75f6ff9581965 +size 2693928 diff --git a/src/bindings/Nethermind.Crypto.Lean/runtimes/linux-x64/native/libnethermind_lean.so b/src/bindings/Nethermind.Crypto.Lean/runtimes/linux-x64/native/libnethermind_lean.so index 3c520a4..22bef2f 100644 --- a/src/bindings/Nethermind.Crypto.Lean/runtimes/linux-x64/native/libnethermind_lean.so +++ b/src/bindings/Nethermind.Crypto.Lean/runtimes/linux-x64/native/libnethermind_lean.so @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:da362f523f55c4aef69b1cd361c671d3bf0bf9995506483db4e76a13c9dc3e55 -size 3261480 +oid sha256:0aab601787d7fdb1c162abcc023bb56ba09517d0ce6d4b0cbe3e3d1c40fc3dbb +size 3270672