--- 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. ## 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. [^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" } ``` ## Copyright Copyright and related rights waived via [CC0](../LICENSE.md).