EIP-8355 - Precompiles for ML-DSA Verification

Created 2026-07-30
Status Draft
Category Core
Type Standards Track
Authors

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 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 and RFC 8174.

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) 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 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:

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; 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.

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 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

Copyright and related rights waived via CC0.