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.
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 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.
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 and RFC 8174.
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 |
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 |
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.
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.
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 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.
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)
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.
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
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.
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.
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.
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.
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.
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.
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:
std, no-
unsafe, and constant-time, and another that is formally verified and ships
an AVX2 backend.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.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.
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.
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; 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~.
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.
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.
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.
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 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.
{
"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"
}
```
Copyright and related rights waived via CC0.