We wrote a fully FIPS 204-compliant ML-DSA-44 signature verifier that runs on today’s EVM, at 1.23M gas per verification. That is 6.6× cheaper than the previous state of the art, 8.09M gas.
We ran this as an autonomous auto-research project: 144 agents across nine days, spending around $7,500 on inference. At the average gas price this year, the endeavor recoups its cost after less than 900 onchain signature verifications.
Why Gas Is the Constraint
Ethereum’s roadmap points away from native signature verification. Lean Ethereum argues for a smaller protocol, and the stated endgame is to “de-enshrine ECDSA from the protocol fully”, leaving agility to the account layer. A post-quantum verifier therefore has to be implemented as an EVM contract.
On Solana, a post-quantum signature costs whatever the native verifier costs in wall-clock time. On Ethereum, with no precompile planned, it costs whatever the EVM implementation costs in gas. That number decides whether post-quantum accounts are usable today.
The best published number for NIST-compliant ML-DSA verification on the EVM was 8.09M gas, from ZKNox’s ETHDILITHIUM, an Ethereum Foundation-supported effort. It is our baseline.
The Result
| Implementation | Gas | Status |
|---|---|---|
| MLDSA44Verifier | 1.23M | measured |
| ETHDILITHIUM (best published prior art) | 8.09M | measured |
| ETHDILITHIUM with EIP-7885‘s proposed NTT precompile | ~5.73M | the EIP’s own projection |
Neither number includes registering a key. The verifier reads a 20,545-byte expanded public key. That key is deployed once as contract code, at roughly 4.1M gas in code deposit. The published 8.09M baseline reads that key. That is a per-key setup cost, not a per-verification one.
This post is about ML-DSA-44 (FIPS 204) on its standard-compliant path. EVM-tuned variants that swap SHAKE for Keccak are cheaper, but they are different schemes. They are out of scope, as are other post-quantum families.
How We Saved 6.9M Gas
| Component | Baseline | Ours |
|---|---|---|
| SHAKE-256 (9 permutations + sponge) | ~3.00M | ~0.40M |
| NTT (9 number-theoretic transforms) | ~1.85M | ~0.44M |
| Matrix multiply + c·t1 | ~0.85M | ~0.22M |
| Expanded-key loading | ~1.03M | ~5K |
| Signature decode (z, hints) | ~0.94M | ~86K |
| UseHint + w1 encoding | ~0.36M | ~72K |
| Memory expansion (quadratic) | ~1.80M | ~7K |
| Total (measured end-to-end) | 8.09M | 1.23M |
(The baseline column is a per-stage profile. Its rows sum to more than the 8.09M total. The quadratic memory-expansion term is a global cost, counted both on its own line and inside the stages that allocate. Only the end-to-end totals are apples-to-apples.)
SHAKE-256 Hashing
ML-DSA hashes with SHAKE-256. The EVM’s KECCAK256 opcode cannot compute it, so every SHAKE call has to run Keccak-f[1600] in ordinary opcodes. The baseline paid ~153K gas per permutation.
Keccak-f[1600] keeps its state as 25 numbers of 64 bits each, and scrambles them with the same step 24 times over. The step rotates those numbers. The EVM has no rotate opcode.
A 64-bit lane sits in the low bits of a 256-bit word (0x00…00 || v). A rotate is shift left, shift right, OR, then AND with a 64-bit mask, because the left shift spilled bits of v into the zeros above it. Keccak does that 29 times per round.
We store four copies instead (v || v || v || v). A left shift now spills each copy into the next copy of the same value, which is the wraparound a rotate wants: the high bits of one v land in the low bits of the next v, and because every slot holds the same v, every slot becomes the rotated lane. Bits that fall off the top of the word are the bits the right shift brings in. OR the two shifts and you already have four copies of the rotated lane. The mask is gone.
The step also needs two sets of fixed constants: how far to rotate each of the 25 numbers, and one value XORed in per pass. Those never change, and the baseline still wrote both tables into memory on every call. We unroll all 24 passes into straight-line code with the constants baked in as literals, and put the 25 numbers at fixed addresses 0x00, 0x20, 0x40 and so on, so a load is mload(0x40) instead of mload(add(base, 0x40)). The unrolled permutation is about 21KB. That does not fit in the verifier under EIP-170’s 24,576-byte cap, so it lives in a helper contract the verifier calls.
153K → 41.7K gas per permutation. Nine of those are 1.38M → 0.37M.
Four Coefficients Per Word
All ML-DSA arithmetic is on 256-coefficient polynomials modulo a 23-bit prime. An EVM multiply costs the same whether you use 23 bits of a word or all 256, so we pack four coefficients per word. One multiply of that word by the same constant is four products, as long as each product stays in its own 64 bits and does not spill into the next coefficient.
The NTT (number-theoretic transform, the FFT of this arithmetic) runs nine 256-point transforms per verification, eight layers each. A textbook NTT loads and stores every layer. We load a group of words, run two or three layers on the stack, and store once. Additions stay unreduced across layers: the values grow, but they stay inside those 64 bits. Reduction happens only at multiplies.
One 256-point forward NTT drops from 182K gas to 45.7K.
The signature carries a polynomial z: 1,024 coefficients, each stored as an 18-bit field. Four fields are 72 bits, which is exactly 9 bytes, so they fit in one load. The baseline pulled each field with several byte loads and a bounds check. We split the 9 bytes in register and write four coefficients into one packed word, which is already the layout the NTT reads. ~0.94M → ~86K.
Quadratic memory expansion cost
The EVM charges for memory with a quadratic term in the highest address the call ever touches. The baseline peaked at 953KB and paid ~1.8M gas for it.
Verification needs the expanded matrix  and the NTT of t1, not the 1,312-byte wire key. Both sides store that blob as a data contract. The baseline then unpacked it into one coefficient per word (128KB) and abi.decoded nested arrays: ~1.03M gas. We keep the packed layout prepare.py already wrote, and copy one row at a time into a reused 5KB scratch with EXTCODECOPY. ~1.03M → ~5K, and 128KB of expanded key never sits in memory.
Packed layouts and the 5KB scratch keep the peak near 41KB. That cuts the memory-expansion bill from ~1.8M gas to ~7K.
Formal Verification
A gas-golfed cryptographic verifier is the kind of code that should frighten people.
So we put a lot of effort into verifying it:
- 320 tests, among them the full official NIST ACVP vector sets verified on-chain (including must-reject cases), the Wycheproof corpus, and a 100-key breadth suite.
- 62 machine-checked arithmetic properties, 794 individual claims, proved in Z3 and pinned to the shipped source.
- 64 Lean 4 theorems about the exact EVM opcode semantics of the hot kernels.
- Mutation testing: we broke the verifier 50 different ways, one at a time, and checked whether the suite noticed.
With all that being said, at this stage it’s still research code that hasn’t been audited. Try the verifier, the full test and proof stack, along with every optimization with its measurement here!