Semaphore v4.14.2snarkjs production setup1 176 ms on Cortex-M33variance 0.030 %
Synthetic x² = y benchmarks are fine for wiring things up, but they don’t answer the question that actually matters: can this verify the proofs people use in production? So I took a real Semaphore v4.14.2 Groth16 proof (Merkle tree depth 10, 4 public inputs), produced by snarkjs under the CDN-hosted production trusted setup, and ran it through zkmcu-verifier on a $7 Pi Pico 2 W.
1,176 ms on Cortex-M33, 1,564 ms on Hazard3 RV32, every iteration returning ok=true, iteration-to-iteration variance 0.030 %. That’s the tightest measurement in the whole project. Same proof bytes that Ethereum’s Semaphore precompile accepts at the other end of the wire.
Prover side: @semaphore-protocol/proof 4.14.2 → snarkjs → production snark-artifacts (tag 4.13.0) fetched from the Semaphore CDN. Same code paths the JavaScript SDK uses when generating a Semaphore proof for on-chain submission.
Wire format: snarkjs emits the proof in Ethereum’s EIP-197 byte order. zkmcu-host-gen converts from snarkjs JSON to the .bin files that land in crates/zkmcu-vectors/data/semaphore-depth-10/.
On-device verify: zkmcu-verifier parses those bytes and runs the Groth16 pairing check with substrate-bn.
The Semaphore verifier call that runs on Ethereum’s precompile during every anonymous group message now runs unmodified on a $7 MCU. Practical applications that open up once the verify is on-device:
Hardware wallets that verify before signing
Today your phone runs the Semaphore verify and your Ledger / Trezor just signs the resulting transaction. A compromised phone can trick the hw wallet into authorising a bogus Semaphore action. zkmcu lets the hw wallet’s secure element run the verify itself, so the signing step can refuse if the proof doesn’t actually hold up.
Offline Semaphore gates
Turnstile, door lock, voting booth that accepts a Semaphore-style “I’m in this group without revealing wich member” proof without any server call. Hardware + zkmcu firmware + a Semaphore VK baked in at provisioning. That’s the whole stack.
Peer-to-peer private vouchers
Person A hands Person B a ZK-proven payment note or access credential. Person B’s device verifies locally. No on-chain step needed for the verification side of the exchange (settlement can happen separately).
Mid-transit attestation
IoT devices forwarding SNARK-attested sensor readings or identity claims downstream, with each hop verifying the previous hop’s ZK signature on-MCU rather than trusting the network.
The hardcoded inputs in gen.ts (identity seed, message, scope, tree depth) make the whole pipeline byte-deterministic. Rerunning produces the same .bin files, verifiable against the committed SHA-256 on the repo.
Not every Semaphore-shaped circuit will verify in exactly 1,176 ms. Tree depth affects witness size, not VK or public-input count, so a firmware implementing pairing_batch the same way lands on the same timing profile. Different depth changes the proving cost on the host, not the verify cost on the MCU.
Not a constant-time implementation. Verify duration varies observably with public-input Hamming weight (see benchmarks) because substrate-bn uses a sliding-window NAF. Acceptable for verify-only threat models where proof + public inputs are already public, not acceptable if secret data flows into the verify path.
Not a performance lower bound. substrate-bn’s pure-Rust implementation doesn’t use ARMv8-M UMAAL / SMLAL intrinsics, wich would plausibly cut verify by 2-3×. That’s future optimization work, ofcourse.