EIP-8355 ML-DSA signature verification precompiles (Precompiles for ML-DSA Verification)
Hegota Declined for Inclusion · Security & privacy · EIP status Draft · created 2026-07-30
Adds three precompiles at addresses 0x12, 0x13 and 0x14 that verify the post-quantum signature scheme ML-DSA (FIPS 204). Given the public key, signature and message concatenated without length prefixes, they return a 32-byte validity result.
Why it is needed
Ethereum does not yet have a post-quantum signature verifier that contracts can call. ML-DSA is a signature scheme based on lattice mathematics, and it is the first lattice signature that the US National Institute of Standards and Technology (NIST) published as a final standard (FIPS 204). Cloud key management services and hardware security modules already generate ML-DSA keys at three security levels and sign with them, and certificate and transport protocol specifications use the same FIPS 204 encoding. A precompile that accepts that encoding as is can verify signatures made by any device, and a key owner can protect an account with that key without ever taking the key out. EIP-8051 has the same goal, but it covers only security level II (ML-DSA-44) and fixes the message to a 32-byte hash. This proposal judges that an account authentication key protects funds for years and therefore needs more margin than 128 bits. So it includes the higher security levels III and V as well, and leaves the message length free, exactly as in FIPS 204.
What changes
Today
- There is no post-quantum signature verifier that contracts can call.
- ECRECOVER and secp256r1 verification return an empty value on failure, so it is impossible to tell whether the signature is wrong or the chain lacks the precompile.
- EIP-8051, which has the same goal, covers only ML-DSA-44 as a single level and fixes the message to a 32-byte hash.
After EIP-8355
- Verifies ML-DSA-44, 65 and 87 signatures at addresses 0x12, 0x13 and 0x14.
- The result is always 32 bytes. Zero means the signature failed, and anything other than 32 bytes means the chain lacks the precompile.
- The message is passed as is with no length limit, and the public key uses the FIPS 204 compressed encoding.
Who is affected
- Users: An account can be protected with an ML-DSA key held in a cloud key management service or a hardware security module. The key never needs to be taken out.
- Wallet & app developers: Contracts can verify ML-DSA signatures directly. However, unlike ECRECOVER, the caller must check the returned 32 bytes with ISZERO rather than the call's success flag, and any chain or contract separation must be put into the message directly.
- Client teams: There is no need to write lattice code yourself. You can connect to the proven ML-DSA library of each language, such as Java, Rust, Go, C# and C. Gas must be deducted before verification starts.
- L2s and other chains: Each address can enable a security level and set its gas price separately. Even if the same bytecode is deployed on several chains, the return length tells you whether the precompile exists.
In depth
Three addresses and sizes per parameter set
The three precompiles sit at consecutive addresses. VERIFY_MLDSA44 is at 0x12, VERIFY_MLDSA65 at 0x13 and VERIFY_MLDSA87 at 0x14, corresponding to ML-DSA-44, ML-DSA-65 and ML-DSA-87 in FIPS 204. The public key uses the FIPS 204 standard compressed encoding pk = (ρ, t1), and the signature uses the standard signature encoding. The sizes are fixed per parameter set. ML-DSA-44 has PK_LEN 1312 and SIG_LEN 2420, so the minimum input is 3732 bytes. ML-DSA-65 has 1952 and 3309, giving 5261 bytes. ML-DSA-87 has 2592 and 4627, giving 7219 bytes. There are three reasons for splitting addresses instead of using a single precompile that separates the three levels with a selector byte. Each address has a fixed input shape and gas, so no branch byte needs to be parsed. There is no room to confuse the selector with the first byte of the public key. And a chain can enable each level and set its gas price separately.
Input format: public key, signature, message, in that order, with no length prefixes
The input is a single big-endian byte string with no header and no length fields. Starting at offset 0, the first PK_LEN bytes are the pubkey, the next SIG_LEN bytes are the signature, and the remaining len(input) - PK_LEN - SIG_LEN bytes are the message. With L = len(input), if L < PK_LEN + SIG_LEN, the precompile MUST return failure (32 zero bytes) without reverting. L == PK_LEN + SIG_LEN is a valid input with an empty message, because FIPS 204 allows an empty message. Putting the message last and dropping the length field is a choice to keep input assembly simple. You write the two fixed-length fields first, append the message, and call with that size, so there is no length word to compute or misplace. It also matches how the witness arrives. In EIP-8141, an ML-DSA witness can arrive as an ARBITRARY signature entry holding pubkey ++ signature, and a VERIFY frame moves it into memory with a single SIGPARAM copy. Appending the message that the signature approves after that makes it the precompile input without repackaging or rescanning the witness bytes.
Empty context string and responsibility for domain separation
The input has no context string, and the precompile always uses the FIPS 204 § 5.3 default, the empty context ctx = "". FIPS 204 lets a context of up to 255 bytes be bound to the message representation, but the X.509 certificate and TLS specifications that use ML-DSA all keep the default and leave domain separation to the surrounding protocol. This precompile is in the same position. The message being verified is already a protocol-specific commitment, and a caller who wants more separation can prepend it to the message. A context field would need a length tag, which revives the header that this layout tries to avoid, and several widely used cryptographic libraries expose only empty-context verification, so clients would have to write the verifier themselves. The Security Considerations make the same point. The precompile itself does no domain separation, so, as with ECRECOVER or secp256r1 verification, the application must put the binding to chain, contract, nonce and intent into the message. If one key is used for several signature schemes, the separation must be done inside the message. An authentication contract built on EIP-8141 meets this condition by signing the frame hash, which commits to the frame being approved.
Verification semantics and fixed 32-byte output
Following FIPS 204, the precompile computes ML-DSA.Verify(pubkey, message, signature) over the raw message. It also performs the matrix expansion  = ExpandA(ρ) and the internal hash μ = H(BytesToBits(tr) ‖ M') itself, and returns success only when verification passes. Malformed encodings, such as coefficients out of range, a non-canonical hint h, or a ‖z‖ bound violation, MUST return failure rather than revert. This is so that callers can treat 'invalid signature' as a boolean. The output is always exactly 32 bytes. 0x00..01 is a valid signature, and 0x00..00 is an invalid or malformed signature, or an input shorter than PK_LEN + SIG_LEN. Because it does not revert except on gas exhaustion, the call success flag carries no signature information, and calling code must check the returned word with ISZERO along with the success flag. The fixed-length return is a deliberate difference from ECRECOVER and the EIP-7951 secp256r1 verification, which return an empty byte string on failure. A call to an address with no code also succeeds and returns an empty value, so with the empty-value approach 'invalid signature' and 'fork not activated or address not assigned on this chain' are byte-for-byte identical. This is the repeated complaint about P256VERIFY. In this proposal, a return length other than 32 means the precompile is absent, and 32 zero bytes means the signature failed. The cost is one word of memory and the accidental fail-closed behavior that Solidity gained when decoding an empty return value, and nothing more.
Gas schedule and upfront deduction before verification
Gas is BASE + 6 * ceil(max(0, L - PK_LEN - SIG_LEN) / 32). BASE is 6500 for VERIFY_MLDSA44, 9000 for VERIFY_MLDSA65 and 13500 for VERIFY_MLDSA87, and each message word costs 6 gas. Thanks to the max(0, ...) clamp, an input shorter than PK_LEN + SIG_LEN pays exactly BASE and is rejected, with no special rule. The base cost makes up most of the total and covers the matrix expansion and the number-theoretic transform (NTT) verification work. The small linear term is the cost of the SHAKE256 hash over the message, which is cheap compared with the lattice operations. 6 gas per word is the same rate the KECCAK256 instruction already charges. SHAKE256 and Keccak-256 absorb with the same Keccak-f[1600] permutation at the same 136-byte rate, so the per-byte cost is the same. The base cost is higher than EIP-8051's 4500 because the matrix is expanded directly from ρ rather than received pre-expanded. Clients MUST deduct the full gas before the verification work. The cost depends only on input length, so it can be computed in advance. Deducting after verification creates a denial-of-service surface where an attacker can make clients do work for free in transactions that end with insufficient gas. The source states that these figures predate reference implementation benchmarks and were set slightly higher than a pairing check of similar workload.
Two choices that differ from EIP-8051: compressed public keys and variable-length messages
EIP-8051 accepts a 20512-byte public key with the matrix materialized and t1 held in the NTT domain, which skips the expansion. This proposal deliberately reverses that trade. The public key travels in the witness alongside the signature, so its size is paid on every verification as transaction bandwidth and data cost, whereas the expansion is paid only once as an operation that can be priced in gas. 20512 bytes is more than fifteen times the 1312-byte compressed ML-DSA-44 key and larger than an entire ML-DSA-87 witness. The judgment is that charging every transaction that cost to save a client work that finishes in microseconds is backwards, and a chain that wants the opposite trade can add a sibling precompile that accepts pre-expanded keys. Message length differs too. EIP-8051 fixes it to a 32-byte pre-hash, but ML-DSA is not a fixed-length-message scheme, and FIPS 204 § 5.4 defines pre-hashing as a separate construction, HashML-DSA, which binds the identifier of the hash function that produced the digest. A bare 32-byte message has no such binding, and a 32-byte-only precompile cannot verify HashML-DSA signatures that grow longer because of the identifier and domain separator. What remains is a third construction, neither pure ML-DSA nor HashML-DSA, for which the caller must supply the security argument. A caller that holds only a digest can still pass the digest and take that responsibility, but this is not forced on callers that can sign the message directly.
Implementation maturity and test cases
ML-DSA has a verified implementation in every language that execution layer clients use, so a client can link a library instead of writing new lattice code. In Java, the SUN provider supports all three parameter sets from JDK 24, and in Rust there is a formally verified crate with a no-std, no-unsafe, constant-time crate and an AVX2 backend. In Go, the standard library crypto/mldsa from 1.27 is wired into crypto/x509 and TLS, and C# and .NET expose a native ML-DSA type from .NET 10. For C, besides the reference implementation and liboqs there is a portable C90 implementation, and Node.js verifies all three sets through Web Crypto. The test cases use VERIFY_MLDSA44 to show the three results a caller can see. Cases 1 and 2 are tcId 1 and 8 of group 1 in Wycheproof mldsa_44_verify_test.json, which use the same public key and the message "Hello world" and differ only by one bit in the first byte of the signature's c~. Case 1 returns 0x00..01 for a 3743-byte input at 6506 gas, case 2 returns 0x00..00 at the same length and gas, and case 3 returns 0x00..00 without verification for the 21-byte "This is invalid input" at 6500 gas. The call itself succeeds in all three cases, so call success alone cannot distinguish them. Before Last Call, the remaining work is to re-encode the full NIST ACVP and Wycheproof sets for all three parameter sets into this layout and add EVM-specific cases such as the empty message and boundary lengths.
Backwards compatibility, Draft status, and the Hegotá fork discussion
The precompile uses an unassigned address, so existing behavior does not change. Before the activation fork, a call to this address returns empty output, and after the fork it returns a 32-byte word and charges the gas schedule above. This difference in return length is how a contract can detect whether the precompile exists, and no deployed contract is known to depend on the previous behavior. The status of this EIP is Draft. The gas figures predate reference implementation benchmarks and the test vectors are to be extended before Last Call, so the specification may change. In core developer calls, it was accepted as a Hegota fork candidate (PFI) at ACDE #243 on 08.13. The intent was to use a standard library for FIPS 204 post-quantum signature verification instead of having clients implement it themselves. However, at ACDE #247 on 10.08 it was declined for the Hegota fork (DFI). There was no consensus on lattice-based signatures, concern about zkEVM cost was strong, and a hash-based approach was preferred.