---
eip: 8355
title: Precompiles for ML-DSA Verification
description: Three precompiles verifying ML-DSA signatures from a concatenated public key, signature, and trailing message
author: Danno Ferrin (@shemnon)
discussions-to: https://ethereum-magicians.org/t/eip-8355-precompiles-for-ml-dsa-verification/29211
status: Draft
type: Standards Track
category: Core
created: 2026-07-30
---

## Abstract

Three precompiles verify Module-Lattice-Based Digital Signature Algorithm
(ML-DSA) signatures[^fips204] at NIST security levels II, III, and V,
corresponding to parameter sets ML-DSA-44, ML-DSA-65, and ML-DSA-87.
Each precompile takes a single concatenated input of the form `pubkey ++
signature ++ message` with no length prefixes. The public key and the signature
have fixed lengths defined by the parameter set, so the message is exactly the
trailing bytes. Each precompile returns a 32-byte left-padded word: one on a
valid signature and zero otherwise.

## Motivation

Ethereum needs a post-quantum signature verifier that a contract can call.
ML-DSA is the first lattice signature scheme NIST has published as a final
standard, and the surrounding ecosystem has converged on it: cloud key
management services and hardware security modules generate and sign with ML-DSA
keys at all three parameter sets, and the certificate and transport profiles
being written around it consume the same FIPS 204 encodings. A precompile that
takes those encodings unmodified can verify a signature produced by any of them,
so an account can be backed by a key its holder never has to export.

[EIP-8051](./eip-8051.md) also proposes ML-DSA verification, but two of its
choices narrow what can be verified. This proposal differs from it in these two
ways.

First, this EIP covers NIST security levels III and V in addition to level II.
EIP-8051 specifies only ML-DSA-44, which targets 128-bit classical security. An
account authenticator is long-lived and holds funds for years, so it wants
margin above 128 bits, and ML-DSA-65 and ML-DSA-87 are the higher tiers NIST
recommends for exactly that reason.

Second, it places a variable-length message last and infers its length from the
total input size, where EIP-8051 fixes the message at a 32-byte pre-hash. ML-DSA
is not a fixed-length message scheme: FIPS 204 defines signing and verification
over a message of any length, and the signature and public key are the only
fixed-size values in the algorithm. A verifier that accepts exactly 32 bytes
implements a strict subset of the scheme and forces every caller holding a longer
message to pre-hash it first. Taking the message as the trailing bytes restores
the full domain at no cost, since the two fixed-size fields precede it and its
length follows by subtraction.

## Specification

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD",
"SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this
document are to be interpreted as described
in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119)
and [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174).

### Precompile addresses

Three precompiles are added at a contiguous block of addresses.

| Precompile       | Address | FIPS 204 Parameter set |
|------------------|---------|------------------------|
| `VERIFY_MLDSA44` | `0x12`  | ML-DSA-44              |
| `VERIFY_MLDSA65` | `0x13`  | ML-DSA-65              |
| `VERIFY_MLDSA87` | `0x14`  | ML-DSA-87              |

### Sizes

Public keys use the standard compressed FIPS 204 encoding `pk = (ρ, t1)`, and
signatures use the standard FIPS 204 signature encoding.

| Parameter set | `PK_LEN` | `SIG_LEN` | Minimum input |
|---------------|----------|-----------|---------------|
| ML-DSA-44     | 1312     | 2420      | 3732          |
| ML-DSA-65     | 1952     | 3309      | 5261          |
| ML-DSA-87     | 2592     | 4627      | 7219          |

### Input format

The input is a single byte string, big-endian throughout, with no header and no
length fields.

```
offset 0                : pubkey     (PK_LEN bytes)
offset PK_LEN           : signature  (SIG_LEN bytes)
offset PK_LEN+SIG_LEN   : message    (len(input) - PK_LEN - SIG_LEN bytes)
```

Let `L = len(input)`. If `L < PK_LEN + SIG_LEN` the precompile MUST return
failure (32 zero bytes) without reverting. A message of length zero is
permitted, so `L == PK_LEN + SIG_LEN` is a valid input; FIPS 204 admits the
empty message. Otherwise the fields are `pubkey = input[0 : PK_LEN]`,
`signature = input[PK_LEN : PK_LEN + SIG_LEN]`, and
`message = input[PK_LEN + SIG_LEN : L]`.

The input carries no context string; the precompile always uses the empty
context `ctx = ""` that FIPS 204 § 5.3 gives as the default. A caller needing
domain separation includes it in the message.

### Semantics

The precompile computes `ML-DSA.Verify(pubkey, message, signature)` per FIPS 204
over the raw message, performing the matrix expansion `Â = ExpandA(ρ)` and the
internal `μ = H(BytesToBits(tr) ‖ M')` hashing itself. It returns success if and
only if verification passes.

On a malformed encoding, including out-of-range coefficients, a non-canonical
hint `h`, and a `‖z‖` bound failure, the precompile MUST return failure rather
than revert, so that a caller can treat "invalid signature" as a boolean.

### Output

The precompile always returns exactly 32 bytes. The value `0x00..01` indicates a
valid signature. The value `0x00..00` indicates a signature that is invalid or
malformed, or an input shorter than `PK_LEN + SIG_LEN`.

The precompile never reverts on cryptographic failure or invalid calldata; only
exhausting gas ends the call abnormally. Caller code can therefore branch on the
returned word with `ISZERO`.

### Gas cost

Gas is `BASE + 6 * ceil(max(0, L - PK_LEN - SIG_LEN) / 32)`, where `BASE`
depends on the parameter set. The `max(0, ...)` clamp makes an input shorter
than `PK_LEN + SIG_LEN` cost exactly `BASE`, so the too-short case is charged
and rejected without a special rule.

| Precompile       | Base gas | Per message word |
|------------------|----------|------------------|
| `VERIFY_MLDSA44` | 6500     | 6                |
| `VERIFY_MLDSA65` | 9000     | 6                |
| `VERIFY_MLDSA87` | 13500    | 6                |

The base cost dominates and covers the matrix expansion and the
number-theoretic transform (NTT) verification work. The small linear term covers
hashing the message, which is cheap relative to the lattice arithmetic.

Clients MUST charge the full gas cost before performing any verification work.
The cost depends only on the input length, so it can be computed and deducted up
front.

## Rationale

### Always returning 32 bytes

These precompiles deliberately match neither existing verifier. `ECRECOVER`
returns an empty byte string on failure, which Solidity surfaces to the caller
as a zero address, and secp256r1 verification ([EIP-7951](./eip-7951.md))
returns an empty byte string as well. Returning a fixed 32 bytes instead lets a
caller tell a failed verification apart from a precompile that is not present.

A call to an address with no code succeeds and returns nothing, so under an
empty-on-failure encoding "this signature is invalid" and "this chain has not
activated the fork, or is a chain where these addresses were never assigned"
produce byte-identical results. A contract cannot then detect that its verifier
is missing except by trusting every signature or rejecting every signature, and
this ambiguity is a recurring complaint about `P256VERIFY`. Here a returndata
length other than 32 means the precompile is absent, which a caller can check
once and act on, while a 32-byte zero means the signature did not verify.

The cost of the choice is one word of memory and the loss of the accidental
fail-closed behavior a Solidity caller gets from decoding empty returndata.
Neither outweighs being able to distinguish the two failures, particularly for
verification logic deployed to more than one chain, which cannot assume the
precompile exists everywhere the same bytecode runs.

### Message last, with no length field

Placing the message last and omitting a length field keeps input assembly
trivial. The two fixed-size fields come first, so a caller writes the public
key, then the signature, then the message to the end of its buffer and calls the
precompile with the resulting size. There is no length word to compute, place,
or get wrong.

The layout also matches how a witness arrives. Under [EIP-8141](./eip-8141.md)
an ML-DSA witness may travel as an `ARBITRARY` signature entry holding
`pubkey ++ signature`, which a `VERIFY` frame copies into memory with a single
`SIGPARAM` copy. Writing the message that the signature authorizes immediately
after that copy produces the precompile input with no repacking and no second
pass over the witness bytes.

### Variable-length messages

Accepting a message of any length, rather than the fixed 32 bytes of EIP-8051,
also keeps the precompile inside FIPS 204. A 32-byte input can only be a digest,
so that design forces every caller into pre-hashing, and FIPS 204 § 5.4 makes
pre-hashing a distinct construction: HashML-DSA binds the digest to the
identifier of the hash function that produced it, so a signature over a digest
cannot be reinterpreted as one over a different message under a different hash.
A bare 32-byte message carries no such binding, and a 32-byte-only precompile
cannot verify a HashML-DSA signature either, since the encoded identifier and
domain separator push the message representative past 32 bytes. What remains is
neither pure ML-DSA nor HashML-DSA, but an unnamed third construction whose
security argument the caller must supply. Callers remain free to pass a digest
and take on that obligation, as a caller that holds only a digest must, but the
precompile should not force it on callers who can sign a message directly.

### Empty context string

FIPS 204 lets an application pass a context string of up to 255 bytes, bound
into the message representative and empty by default. Fixing it to empty follows
the profiles that use ML-DSA: the X.509 certificate and TLS specifications both
leave the context at its default and rely on the surrounding protocol for domain
separation. This precompile is in the same position, since the message it
verifies is already a protocol-specific commitment, and a caller wanting further
separation can prefix it onto the message.

A context field would also be length-tagged, reintroducing the header this
layout exists to avoid, and it would cost more than its bytes. Not every
cryptographic library exposes the parameter; several widely used ones verify
with the empty context only and document application-supplied contexts as future
work. Accepting a context would push clients built on those libraries onto a
hand-rolled verifier, for a field the deployed protocols leave empty anyway.

### One address per parameter set

Three separate addresses were chosen over a single precompile switching on a
leading selector byte. Each address then has a fixed input shape and its own
flat gas cost, with no branch byte to parse and no ambiguity between a level
selector and the first byte of a public key. Separate addresses also let a chain
enable and meter the levels independently.

### Compressed public keys

Taking the compressed public key rather than a pre-expanded one reverses the
EIP-8051 tradeoff deliberately. EIP-8051 accepts a 20512-byte key with the
matrix materialized and `t1` in the NTT domain, letting its precompile skip the
expansion. That tradeoff assumes the expanded key is cheap to move, and it is
not. The public key accompanies the signature in the witness, so its size is
paid in transaction bandwidth and data cost every time a signature is verified,
while the expansion it saves is paid once, in compute the gas schedule can price
directly. At 20512 bytes the expanded key is more than fifteen times the
1312-byte compressed ML-DSA-44 key and larger than an entire ML-DSA-87 witness.
Charging every transaction for that to save work a client can do in
microseconds is the wrong way round, so these precompiles take the compressed
encoding and price the expansion into the base cost. A chain wanting the
opposite tradeoff could add sibling precompiles that take pre-expanded keys.

### Gas schedule

The base costs are higher than the 4500 gas EIP-8051 charges because these
precompiles expand the matrix from `ρ` rather than receiving it pre-expanded.
The linear term exists only to price the SHAKE256 pass over the message, which
is why it is small relative to the base. Six gas per word is the rate the
`KECCAK256` opcode already charges, which aligns with the crypto: SHAKE256 and
Keccak-256 are the same Keccak-f[1600] permutation absorbing at the same
136-byte rate, so a client pays the same per byte either way. The figures given
here require benchmarking against a reference implementation before they are
final; they are sized to sit modestly above equivalent-work pairing checks.

### Implementation maturity

Unlike a novel curve or a bespoke construction, ML-DSA already has high-quality
implementations in every language an execution-layer client is written in, and
in the browser tier that light clients and dapps run in. Clients can bind to a
vetted implementation rather than write lattice code from scratch, which makes
shipping the precompile low-risk. At the time of writing:

- **Java** has native key generation and signature support for all three
  parameter sets in the SUN provider, since JDK 24.
- **Rust** has several pure-Rust crates, including one that is no-`std`, no-
  `unsafe`, and constant-time, and another that is formally verified and ships
  an AVX2 backend.
- **Go** has a `crypto/mldsa` standard-library package covering all three
  parameter sets, wired into the standard `crypto/x509` and TLS packages, since
  Go 1.27; the implementation shipped internally one release earlier.
- **C# and .NET** expose a native ML-DSA type with platform-backed providers, as
  of .NET 10.
- **C** has a portable, security-focused C90 implementation alongside the
  reference implementation and liboqs.
- **JavaScript and WebAssembly** have high-quality libraries today, and Node.js
  verifies all three parameter sets natively through its Web Crypto
  implementation. A community specification extends the Web Cryptography API to
  ML-DSA, but it is not yet on the standards track and browser support is early.

The point is not any single library but the breadth: every consensus client
language, and the web platform, can source a maintained ML-DSA verifier.

## Backwards Compatibility

The precompiles occupy previously unassigned addresses, so no existing behavior
changes. Before the activation fork a call to one of those addresses returns
empty output; afterwards it returns a 32-byte word and charges the schedule
above. That difference in returndata length is what lets a contract detect
whether the precompile is available, as described in Security Considerations. No
deployed contract is known to depend on the prior behavior.

## Test Cases

The three cases below cover the three outcomes a caller can observe: a valid
signature, a well-formed signature that does not verify, and an input too short
to parse. All three call `VERIFY_MLDSA44`.

Cases 1 and 2 are Wycheproof `mldsa_44_verify_test.json` (`testvectors_v1`)
group 1, `tcId` 1 and 8. They share a public key and the message
`"Hello world"`; only the signature differs, so the pair isolates verification
failure from every other variable. The assembled inputs for all three cases are
in a separate [file](../assets/eip-8355/test-vectors.json); the table below
gives the SHA-256 of each component so an implementer can confirm the
concatenation independently.

| Component  | Value |
|------------|-------|
| `pubkey`   | 1312 bytes, `db9ac677…3357ab9d`, SHA-256 `d87f8ca136ac1aa55e2d6c4521680efb3a378cbb9bc0bfb446e9c60893931ea3` |
| `msg`      | 11 bytes, `48656c6c6f20776f726c64` (`"Hello world"`) |
| `sig` (1)  | 2420 bytes, `1aa69cb5…121f323e`, SHA-256 `8cd6fc03daa72e87210a4e721523e84c14f27733789075e65736744d4787fdd5` |
| `sig` (2)  | 2420 bytes, `1ba69cb5…121f323e`, SHA-256 `01d3fedf4de15410b44ce29de442c359b17feb756c02423a213f03ab0be3bf40` |

The two signatures differ in one bit, in the first byte of `c~`.

### Case 1: valid signature

Input is `pubkey ++ sig(1) ++ msg`, 3743 bytes, SHA-256
`872ee5f073e71700e7f355e5e086cf380127390a6b5d0cc3e5912f42db4f25ae`.

The call succeeds, returns
`0x0000000000000000000000000000000000000000000000000000000000000001`, and costs
`6500 + 6 * ceil(11 / 32)` = 6506 gas.

### Case 2: well-formed signature that does not verify

Input is `pubkey ++ sig(2) ++ msg`, 3743 bytes, SHA-256
`56d2be7f5895ade316de20f76b4fa7138f8e232fd59fda6ba5faf879ed63b0cd`.

Every field is the correct length and the signature parses, but the flipped bit
in `c~` makes verification fail. The call still succeeds and costs the same 6506
gas as case 1, and returns
`0x0000000000000000000000000000000000000000000000000000000000000000`. A caller
that branches on call success rather than on the returned word cannot tell this
case from case 1.

### Case 3: input too short to parse

Input is the 21 ASCII bytes `5468697320697320696e76616c696420696e707574`
(`"This is invalid input"`).

`L = 21` is below `PK_LEN + SIG_LEN` = 3732, so no field boundary exists and no
verification is attempted. The call succeeds, returns
`0x0000000000000000000000000000000000000000000000000000000000000000`, and costs
`6500 + 6 * ceil(max(0, 21 - 3732) / 32)` = 6500 gas. As in case 2, nothing
about the call frame distinguishes this from a successful verification.

<!-- TODO before Last Call: import the full NIST ACVP `ML-DSA-sigVer-FIPS204`
     set (usnistgov/ACVP-Server, `gen-val/json-files/`) and the Wycheproof
     `mldsa_{44,65,87}_verify` sets for all three parameter sets, re-encoded
     into this concatenated layout. Both label each vector with an expected
     pass/fail, which is the shape this precompile needs, and Wycheproof covers
     the malformed-encoding classes called out in Semantics (invalid hint
     encoding, infinity-norm violation) directly. Add the EVM-specific cases
     those suites do not have: `L == PK_LEN + SIG_LEN` (empty message),
     messages spanning word boundaries, and inputs one byte short of and one
     byte over the minimum. Vectors carrying a non-empty `ctx` are out of scope
     and should not be imported, since this precompile fixes `ctx = ""`. -->

## Security Considerations

Verifying under an empty context string, the precompile provides no domain
separation of its own. This is the usual position for a raw public key
signature: as with `ECRECOVER` and secp256r1 verification, the scheme signs the
bytes it is given and nothing more, so any binding to a chain, a contract, a
nonce, or an intent has to be built into the message by the application. If a
key is ever used across schemes, the caller has to domain-separate within the
message. An authentication contract built on [EIP-8141](./eip-8141.md) satisfies
this by signing over the frame hash, which already commits to the frame being
authorized.

A failed call indicates one thing only: insufficient gas was provided. In every
other case the call succeeds and returns 32 bytes, `0x00..01` if the signature
verified and `0x00..00` if it did not, whether because the input was too short,
the encoding was not well-formed, or the signature was invalid. The success flag
therefore carries no information about the signature. A caller should thus check
both the call success and the return value.

Neither existing verifier returns a fixed-width result on failure, so a caller
ported from `ECRECOVER` or from secp256r1 verification cannot assume empty
returndata means failure here. Returndata of any length other than 32 means the
precompile is not present on the chain, which is a different condition from an
invalid signature and needs to be handled as one.

The gas schedule is charged before verification begins, as specified above.
Metering afterwards would let an attacker obtain the work for free in a
transaction that runs out of gas, a denial-of-service surface.

<!-- markdownlint-capture -->
<!-- markdownlint-disable code-block-style blanks-around-fences -->

[^fips204]:
    ```csl-json
    {
        "id": "https://doi.org/10.6028/NIST.FIPS.204",
        "type": "report",
        "title": "Module-Lattice-Based Digital Signature Standard",
        "author": [
            { "literal": "National Institute of Standards and Technology" }
        ],
        "issued": {
            "date-parts": [[2024, 8, 13]]
        },
        "publisher": "National Institute of Standards and Technology",
        "publisher-place": "Gaithersburg, MD",
        "collection-title": "Federal Information Processing Standards Publication",
        "number": "204",
        "URL": "https://doi.org/10.6028/NIST.FIPS.204",
        "DOI": "10.6028/NIST.FIPS.204"
    }
    ```

<!-- markdownlint-restore -->

## Copyright

Copyright and related rights waived via [CC0](../LICENSE.md).
