From 3222abeeb7e7001c78d2966aebc9ed2ce7e40ef5 Mon Sep 17 00:00:00 2001 From: dkijania Date: Sat, 13 Jun 2026 00:27:15 +0200 Subject: [PATCH 1/2] server: HTTP verify sidecar for a trustless indexer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `mina-verify-server` — a long-lived HTTP service wrapping `Verifier::verify_precomputed_and_extract`: POST a precomputed block, get its proof-backed facts ({valid, height, state_hash, previous_state_hash, staged_ledger_hash}). An indexer gates ingestion on `valid` with no trusted daemon; a valid proof attests the whole chain to genesis by recursion. - Links only the mina-verify lib (no networking) — small, stateless, CPU-bound. Verifier built once at startup; each request is just the proof check. - tiny_http (sync) with a worker pool — verification is blocking CPU work, so no async/tokio. - Reads the body as bytes + lossy UTF-8: real precomputed blocks are NOT strictly UTF-8 (the daemon emits sok_digest in staged_ledger_diff as mixed raw/escaped bytes); that field is ignored by verification, so lossy decode is safe and necessary. Verified against fresh devnet blocks that carry these bytes. - Config via env (BIND, MINA_VK_JSON for mesa, MINA_NETWORK, VERIFY_THREADS). - Dockerfile builds the bin; run as the sidecar via --entrypoint mina-verify-server. Native release verifies in ~1-2s/block, so an indexer can verify every block, not just the tip. README documents the docker-compose indexer topology + the --verify-block-exe curl-shim wiring. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 35 ++++++ Cargo.toml | 1 + Dockerfile | 6 +- crates/mina-verify-server/Cargo.toml | 17 +++ crates/mina-verify-server/README.md | 86 +++++++++++++ crates/mina-verify-server/src/main.rs | 173 ++++++++++++++++++++++++++ 6 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 crates/mina-verify-server/Cargo.toml create mode 100644 crates/mina-verify-server/README.md create mode 100644 crates/mina-verify-server/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 8137e1a..dcad65c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -317,6 +317,12 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "asn1-rs" version = "0.5.2" @@ -711,6 +717,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "cipher" version = "0.3.0" @@ -2998,6 +3010,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "mina-verify-server" +version = "0.0.1" +dependencies = [ + "env_logger", + "log", + "mina-verify", + "serde_json", + "tiny_http", +] + [[package]] name = "mina-verify-wasm" version = "0.0.1" @@ -4850,6 +4873,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.7.6" diff --git a/Cargo.toml b/Cargo.toml index a884d32..58ff707 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/mina-verify-cli", "crates/mina-verify-monitor", "crates/mina-verify-wasm", + "crates/mina-verify-server", ] # Pinned to o1-labs/mina-rust @ ab69eaed. NOTE: upstream (OpenMina) is unmaintained; diff --git a/Dockerfile b/Dockerfile index 5159dd1..774391e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,8 @@ RUN cargo build --release --locked RUN strip \ target/release/mina-verify \ target/release/mina-verify-capture \ - target/release/mina-verify-monitor + target/release/mina-verify-monitor \ + target/release/mina-verify-server FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -21,5 +22,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=builder /src/target/release/mina-verify /usr/local/bin/mina-verify COPY --from=builder /src/target/release/mina-verify-capture /usr/local/bin/mina-verify-capture COPY --from=builder /src/target/release/mina-verify-monitor /usr/local/bin/mina-verify-monitor +COPY --from=builder /src/target/release/mina-verify-server /usr/local/bin/mina-verify-server # default: the live verify-before-ingest monitor (MINA_NETWORK env selects the network) +# Run the verify HTTP sidecar instead with: +# docker run -e MINA_NETWORK=devnet -p 8090:8090 --entrypoint mina-verify-server ENTRYPOINT ["mina-verify-monitor"] diff --git a/crates/mina-verify-server/Cargo.toml b/crates/mina-verify-server/Cargo.toml new file mode 100644 index 0000000..4ac132f --- /dev/null +++ b/crates/mina-verify-server/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "mina-verify-server" +version = "0.0.1" +edition = "2021" +description = "HTTP verify sidecar: POST a precomputed block, get its proof-backed facts." +license = "Apache-2.0" + +[dependencies] +# Only the verify library — no networking (the precomputed path needs none), so this +# stays a small, CPU-bound, stateless service. +mina-verify = { path = "../mina-verify" } +# Synchronous HTTP server: verification is blocking CPU work, so a thread-per-worker +# model fits better than async (no tokio needed). +tiny_http = "0.12" +serde_json = { workspace = true } +log = { workspace = true } +env_logger = { workspace = true } diff --git a/crates/mina-verify-server/README.md b/crates/mina-verify-server/README.md new file mode 100644 index 0000000..e6a48f1 --- /dev/null +++ b/crates/mina-verify-server/README.md @@ -0,0 +1,86 @@ +# mina-verify-server + +A long-lived HTTP **verify sidecar**: POST a precomputed block, get its proof-backed +facts. Built for a **trustless indexer** — verify each block's SNARK proof before +ingesting it, with no trusted daemon. A valid proof attests the entire chain to genesis +by recursion, so trust in whoever produced the block is not required. + +Only the `mina-verify` library is linked (no networking — the precomputed path needs +none), so this stays a small, stateless, CPU-bound service. The expensive verifier setup +is paid once at startup; each request is just the proof check. + +## Endpoints + +``` +GET /health -> { "status": "ok", "network": "devnet" } + +POST /verify (body = precomputed-block JSON) + proof valid -> 200 { "valid": true, "height", "state_hash", + "previous_state_hash", "staged_ledger_hash" } + proof invalid -> 200 { "valid": false, "error": "block proof did not verify" } + undecodable -> 400 { "valid": false, "error": "" } +``` + +The caller ingests iff `valid` is `true`, keyed by the returned (proof-backed) hashes. + +> Real precomputed blocks are **not strictly UTF-8** — the OCaml daemon emits some +> byte-string fields (e.g. `sok_digest` in `staged_ledger_diff`) as mixed raw/escaped +> bytes. Those fields are ignored by verification, so the body is decoded lossily; this is +> expected and harmless. + +## Config (env) + +| var | meaning | default | +|-----|---------|---------| +| `BIND` | listen address | `0.0.0.0:8090` | +| `MINA_VK_JSON` | path to a blockchain verifier-index JSON (any network; **required for mesa / mesa-mut**). Takes precedence. | — | +| `MINA_NETWORK` | embedded-VK network when `MINA_VK_JSON` is unset (`devnet` / `mainnet`) | `devnet` | +| `VERIFY_THREADS` | worker threads (verification is CPU-bound) | available parallelism | + +## Run + +```sh +# native +MINA_NETWORK=devnet cargo run --release -p mina-verify-server + +# docker (image builds all mina-verify bins; override the entrypoint) +docker run -e MINA_NETWORK=devnet -p 8090:8090 --entrypoint mina-verify-server + +curl -s localhost:8090/health +curl -s -X POST --data-binary @block.json localhost:8090/verify +``` + +Verification on a native release build is ~1–2 s per block, so an indexer can afford to +verify **every** block (not just the tip). + +## Trustless-indexer topology + +```yaml +# docker-compose.yml +services: + mina-verifier: + image: mina-verify + entrypoint: mina-verify-server + environment: + MINA_NETWORK: devnet # or mount a VK and set MINA_VK_JSON for mesa + # ports/healthcheck omitted for brevity + + mina-indexer: + image: mina-indexer + # reuse the existing exe-hook pattern: --verify-block-exe points at a shim that + # curls http://mina-verifier:8090/verify and exits non-zero unless {"valid":true} + depends_on: [mina-verifier] +``` + +`verify-block.sh` shim (mirrors the indexer's `--fetch-new-blocks-exe` convention): + +```sh +#!/usr/bin/env bash +# usage: verify-block.sh — exit 0 iff the proof verifies +set -euo pipefail +curl -fsS -X POST --data-binary @"$1" "${VERIFIER_URL:-http://mina-verifier:8090}/verify" \ + | grep -q '"valid":true' +``` + +The sidecar's Rust toolchain (1.94.1 + mina-verify's patched lock) stays bottled up in its +own container — it never touches the indexer's build. diff --git a/crates/mina-verify-server/src/main.rs b/crates/mina-verify-server/src/main.rs new file mode 100644 index 0000000..0014fd9 --- /dev/null +++ b/crates/mina-verify-server/src/main.rs @@ -0,0 +1,173 @@ +//! **Verify sidecar** — a long-lived HTTP service that answers one question: +//! "is this precomputed block's proof honest?" +//! +//! A trustless indexer (or any consumer) POSTs a precomputed-block JSON; the service +//! verifies its Pickles/kimchi SNARK proof and returns the proof-backed facts. A valid +//! proof attests the entire chain to genesis by recursion, so the caller can gate +//! ingestion on the answer without trusting whoever produced the block. +//! +//! Why a service and not a CLI: the expensive part is building the [`Verifier`] (loading +//! the verification key + standing up the proof system) — paid **once** at startup here, +//! so each request is just the proof check. The service is stateless and pure, so run as +//! many replicas as you like. +//! +//! ## Endpoints +//! - `GET /health` → `{ "status": "ok", "network": "" }` +//! - `POST /verify` (body = precomputed-block JSON) → +//! - proof valid: `200 { "valid": true, "height", "state_hash", +//! "previous_state_hash", "staged_ledger_hash" }` +//! - proof invalid: `200 { "valid": false, "error": "block proof did not verify" }` +//! - undecodable: `400 { "valid": false, "error": "" }` +//! +//! ## Config (env) +//! - `BIND` — listen address (default `0.0.0.0:8090`) +//! - `MINA_VK_JSON` — path to a blockchain verifier-index JSON (any network; required +//! for mesa / mesa-mut, which have no embedded VK). Takes precedence. +//! - `MINA_NETWORK` — embedded-VK network when `MINA_VK_JSON` is unset (`devnet` / +//! `mainnet`; default `devnet`). +//! - `VERIFY_THREADS` — worker threads (default: available parallelism). Verification is +//! CPU-bound, so this caps concurrent in-flight verifies. + +use std::sync::Arc; + +use mina_verify::{Verifier, VerifierError}; +use tiny_http::{Header, Method, Request, Response, Server}; + +fn env_or(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} + +/// Build the verifier once: an explicit VK JSON wins (covers mesa / regenerated +/// mainnet), otherwise the embedded VK for `MINA_NETWORK`. +fn build_verifier() -> Result { + if let Ok(path) = std::env::var("MINA_VK_JSON") { + let json = std::fs::read_to_string(&path) + .map_err(|e| format!("cannot read MINA_VK_JSON {path:?}: {e}"))?; + Verifier::with_index_json(&json).map_err(|e| e.to_string()) + } else { + let network = env_or("MINA_NETWORK", "devnet"); + Verifier::for_network_offline(&network).map_err(|e| e.to_string()) + } +} + +fn json_header() -> Header { + Header::from_bytes(&b"Content-Type"[..], &b"application/json"[..]).expect("valid header") +} + +fn respond(req: Request, status: u16, body: serde_json::Value) { + let data = body.to_string(); + let response = Response::from_string(data) + .with_status_code(status) + .with_header(json_header()); + if let Err(e) = req.respond(response) { + log::warn!("failed to send response: {e}"); + } +} + +/// Verify a precomputed-block JSON body and turn the result into (status, json). +fn verify_body(verifier: &Verifier, body: &str) -> (u16, serde_json::Value) { + match verifier.verify_precomputed_and_extract(body) { + Ok(vb) => ( + 200, + serde_json::json!({ + "valid": true, + "height": vb.height, + "state_hash": vb.state_hash.to_string(), + "previous_state_hash": vb.previous_state_hash.to_string(), + "staged_ledger_hash": vb.staged_ledger_hash.to_string(), + }), + ), + // Proof checked and was rejected — a well-formed "no". Caller must NOT ingest. + Err(VerifierError::ProofInvalid) => ( + 200, + serde_json::json!({ "valid": false, "error": "block proof did not verify" }), + ), + // Anything else (couldn't decode the block / VK) is a bad request. + Err(e) => ( + 400, + serde_json::json!({ "valid": false, "error": e.to_string() }), + ), + } +} + +fn handle(verifier: &Verifier, mut req: Request) { + let method = req.method().clone(); + let url = req.url().to_string(); + let path = url.split('?').next().unwrap_or(&url); + + match (&method, path) { + (Method::Get, "/health") => respond( + req, + 200, + serde_json::json!({ "status": "ok", "network": verifier.network() }), + ), + (Method::Post, "/verify") => { + // Real precomputed blocks are NOT strictly UTF-8: the OCaml daemon emits some + // byte-string fields (e.g. `sok_digest` inside `staged_ledger_diff`) as mixed + // raw/escaped bytes. Those fields are ignored by verification (only + // `protocol_state` + the proof, both ASCII, are read), so decode lossily — + // matching how JS clients read these blocks — rather than rejecting them. + let mut bytes = Vec::new(); + if let Err(e) = req.as_reader().read_to_end(&mut bytes) { + respond( + req, + 400, + serde_json::json!({ "valid": false, "error": format!("reading body: {e}") }), + ); + return; + } + let body = String::from_utf8_lossy(&bytes); + let (status, json) = verify_body(verifier, &body); + respond(req, status, json); + } + _ => respond( + req, + 404, + serde_json::json!({ "error": "not found; use GET /health or POST /verify" }), + ), + } +} + +fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + + let verifier = Arc::new(build_verifier().unwrap_or_else(|e| { + eprintln!("error: {e}"); + std::process::exit(2); + })); + + let bind = env_or("BIND", "0.0.0.0:8090"); + let threads: usize = std::env::var("VERIFY_THREADS") + .ok() + .and_then(|s| s.parse().ok()) + .filter(|&n| n > 0) + .unwrap_or_else(|| { + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(4) + }); + + let server = Arc::new(Server::http(&bind).unwrap_or_else(|e| { + eprintln!("error: cannot bind {bind}: {e}"); + std::process::exit(2); + })); + + log::info!( + "mina-verify-server listening on {bind} (network={}, {threads} workers)", + verifier.network() + ); + + let mut handles = Vec::with_capacity(threads); + for _ in 0..threads { + let server = Arc::clone(&server); + let verifier = Arc::clone(&verifier); + handles.push(std::thread::spawn(move || { + for req in server.incoming_requests() { + handle(&verifier, req); + } + })); + } + for h in handles { + let _ = h.join(); + } +} From 4c16b05a939dfa1372cd1714ec5dd9fce89a601d Mon Sep 17 00:00:00 2001 From: dkijania Date: Sat, 13 Jun 2026 20:51:25 +0200 Subject: [PATCH 2/2] =?UTF-8?q?server:=20deploy=20glue=20=E2=80=94=20docke?= =?UTF-8?q?r-compose=20+=20verify-block.sh=20gating=20shim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add crates/mina-verify-server/deploy/: - verify-block.sh — the indexer --verify-block-exe shim: curls the sidecar /verify and exits 0 iff {"valid":true}, non-zero => reject. Mirrors the existing --fetch-new-blocks-exe (mesa-pull) curl-wrapper convention. - docker-compose.yml — runs the mina-verifier sidecar with a /health healthcheck, plus a commented mina-indexer stub showing the wiring + mesa MINA_VK_JSON mount. - README.md — the trustless-indexer topology + how to demo gating on devnet. Dockerfile: add curl to the runtime image for the sidecar healthcheck. Verified the gating contract end-to-end against the native sidecar on live devnet: real block -> shim exit 0 (ingest); tampered block -> exit 22 (reject). Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 3 +- crates/mina-verify-server/deploy/README.md | 37 +++++++++++++++++ .../deploy/docker-compose.yml | 41 +++++++++++++++++++ .../mina-verify-server/deploy/verify-block.sh | 24 +++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 crates/mina-verify-server/deploy/README.md create mode 100644 crates/mina-verify-server/deploy/docker-compose.yml create mode 100755 crates/mina-verify-server/deploy/verify-block.sh diff --git a/Dockerfile b/Dockerfile index 774391e..353d30a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,8 +17,9 @@ RUN strip \ FROM debian:bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates libssl3 \ + ca-certificates libssl3 curl \ && rm -rf /var/lib/apt/lists/* +# curl: for the verify-server container healthcheck (GET /health) COPY --from=builder /src/target/release/mina-verify /usr/local/bin/mina-verify COPY --from=builder /src/target/release/mina-verify-capture /usr/local/bin/mina-verify-capture COPY --from=builder /src/target/release/mina-verify-monitor /usr/local/bin/mina-verify-monitor diff --git a/crates/mina-verify-server/deploy/README.md b/crates/mina-verify-server/deploy/README.md new file mode 100644 index 0000000..ed50eb6 --- /dev/null +++ b/crates/mina-verify-server/deploy/README.md @@ -0,0 +1,37 @@ +# Trustless-indexer deployment + +Glue for gating an indexer's ingestion on the verify sidecar. + +- **`docker-compose.yml`** — runs `mina-verifier` (the sidecar) with a `/health` + healthcheck, plus a commented `mina-indexer` stub showing the wiring. +- **`verify-block.sh`** — the `--verify-block-exe` shim: `curl`s the sidecar's + `/verify` and exits `0` iff `{"valid":true}`. The indexer calls it at canonical + promotion; non-zero ⇒ reject the block. + +## Try the gating contract (devnet, no indexer needed) + +```sh +# 1. start the sidecar (native or container) +MINA_NETWORK=devnet cargo run --release -p mina-verify-server # :8090 +# or: docker compose -f docker-compose.yml up mina-verifier + +# 2. point the shim at it and gate a real block vs a tampered one +VERIFIER_URL=http://127.0.0.1:8090 ./verify-block.sh real-block.json ; echo "exit=$?" # 0 → ingest +VERIFIER_URL=http://127.0.0.1:8090 ./verify-block.sh tampered.json ; echo "exit=$?" # !0 → reject +``` + +## Wiring into the indexer + +The indexer reuses its existing exe-hook pattern (like `--fetch-new-blocks-exe`): +pass `--verify-block-exe /app/verify-block.sh` and set `VERIFIER_URL` to the sidecar. +At canonical extension the indexer shells out per block; `valid:true` ⇒ persist, +otherwise reject and log. Gate only at canonical promotion (not losing forks), and +on a mainnet-scale backfill verify the tip + checkpoints rather than every historical +block (recursion already backs the ancestry). At mesa scale, verify-all is fine +(~1–2 s/block). + +## Networks + +devnet/mainnet use the embedded VK. For **mesa / mesa-mut**, mount a verifier-index +JSON into the sidecar and set `MINA_VK_JSON` (see the compose comments) — same shim, +same demo, no code change. diff --git a/crates/mina-verify-server/deploy/docker-compose.yml b/crates/mina-verify-server/deploy/docker-compose.yml new file mode 100644 index 0000000..c35bccd --- /dev/null +++ b/crates/mina-verify-server/deploy/docker-compose.yml @@ -0,0 +1,41 @@ +# Trustless-indexer topology: the indexer gates ingestion on the verify sidecar. +# +# docker build -t mina-verify:sidecar ../../.. # from this repo root +# docker compose -f crates/mina-verify-server/deploy/docker-compose.yml up +# +# devnet/mainnet work out of the box (embedded VK). For mesa/mesa-mut, mount a +# verifier-index JSON and set MINA_VK_JSON instead of MINA_NETWORK. +services: + mina-verifier: + image: mina-verify:sidecar + entrypoint: mina-verify-server + environment: + MINA_NETWORK: devnet + BIND: 0.0.0.0:8090 + # mesa / mesa-mut (no embedded VK): mount the index and use it instead — + # MINA_VK_JSON: /vk/mesa_blockchain_verifier_index.json + # volumes: + # - ./mesa_blockchain_verifier_index.json:/vk/mesa_blockchain_verifier_index.json:ro + ports: + - "8090:8090" + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:8090/health | grep -q ok"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + # The indexer image gates canonical-block ingestion on the verifier. Stubbed — wire + # your indexer image and flags here. The shim (verify-block.sh) is mounted in and + # passed as --verify-block-exe; VERIFIER_URL points it at the sidecar. + # + # mina-indexer: + # image: mina-indexer + # environment: + # VERIFIER_URL: http://mina-verifier:8090 + # volumes: + # - ./verify-block.sh:/app/verify-block.sh:ro + # command: ["mina-indexer", "...", "--verify-block-exe", "/app/verify-block.sh"] + # depends_on: + # mina-verifier: + # condition: service_healthy diff --git a/crates/mina-verify-server/deploy/verify-block.sh b/crates/mina-verify-server/deploy/verify-block.sh new file mode 100755 index 0000000..61a4c6b --- /dev/null +++ b/crates/mina-verify-server/deploy/verify-block.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Indexer --verify-block-exe shim: exit 0 iff the block's SNARK proof verifies. +# +# At canonical promotion the indexer calls this with the precomputed-block file; a +# non-zero exit means "reject — do not ingest". Mirrors the --fetch-new-blocks-exe +# convention (a thin curl wrapper, like mesa-pull), so the warm verifier lives in the +# sidecar and the indexer stays toolchain-clean. +# +# usage: verify-block.sh +# env: VERIFIER_URL (default http://mina-verifier:8090) +# VERIFY_TIMEOUT seconds (default 120) +set -euo pipefail + +block="${1:?usage: verify-block.sh }" +url="${VERIFIER_URL:-http://mina-verifier:8090}/verify" + +# curl -f => non-zero on HTTP errors; set -e => abort the script on that. +resp="$(curl -fsS --max-time "${VERIFY_TIMEOUT:-120}" \ + -H 'Content-Type: application/json' --data-binary @"$block" "$url")" + +echo "verify-block: $resp" >&2 # surface the verdict in indexer logs + +# Final command's status is the script's exit status: 0 iff the proof verified. +printf '%s' "$resp" | grep -q '"valid":true'