Development

Migrating from ECDSA to ML-DSA: A Production Guide for Blockchain Developers

A step-by-step technical guide to replacing ECDSA with ML-DSA (CRYSTALS-Dilithium) in production blockchain systems, covering key sizes, API differences, storage costs, and the case for running both schemes in parallel during the transition window.

QuanChain Research
August 30, 2026
14 min read
Share
Migrating from ECDSA to ML-DSA: A Production Guide for Blockchain Developers

ECDSA has protected blockchain transactions since Bitcoin launched in 2009. It produces compact 64-byte signatures, verification is fast, and the tooling is mature across every language ecosystem. It also relies on the hardness of the elliptic curve discrete logarithm problem, which Shor's algorithm on a sufficiently powerful quantum computer solves in polynomial time. That is the reason every serious blockchain project now needs a migration path to post-quantum signing.

This guide is for developers who need to understand exactly what changes when you swap ECDSA for ML-DSA, what the storage and performance costs look like in real numbers, and how to structure a hybrid transition so you do not break existing clients while you roll out quantum-resistant signing. It is not a theory post. Every section maps to a decision you will face in code.

What Changes Between ECDSA and ML-DSA

Migrating from ECDSA to ML-DSA (NIST FIPS 204, derived from CRYSTALS-Dilithium) requires updating key generation, signing, and verification code, and accepting significantly larger keys and signatures. ECDSA uses 32-byte private keys and 64-byte signatures; ML-DSA-44 uses 2,528-byte private keys and 2,420-byte signatures. All cryptographic logic must change, but the signing interface shape remains similar.

The most immediate difference is size. Here are the concrete numbers for each parameter set side by side with ECDSA:

  • ECDSA (secp256k1): 32-byte private key, 33-byte compressed public key, 64-byte signature (DER-encoded signatures run 71-72 bytes)
  • ML-DSA-44 (Dilithium2, security level 2): 2,528-byte private key, 1,312-byte public key, 2,420-byte signature
  • ML-DSA-65 (Dilithium3, security level 3): 4,000-byte private key, 1,952-byte public key, 3,293-byte signature
  • ML-DSA-87 (Dilithium5, security level 5): 4,864-byte private key, 2,592-byte public key, 4,595-byte signature

For most blockchain applications where transactions need quantum resistance but not the highest-assurance key ceremony, ML-DSA-44 is the starting point. It provides NIST security level 2, roughly equivalent to 128-bit classical security against quantum adversaries. ML-DSA-87 is appropriate for high-value long-lived keys like validator identities or treasury multisig. For background on how these parameter sets were designed, see CRYSTALS-Dilithium explained.

Quick Win

If you are using secp256k1 via a library that exposes a generic sign(private_key, message_hash) interface, you can stub in ML-DSA-44 as a drop-in replacement for the signing call. The only structural changes are the key and signature buffer sizes, which you should already be treating as opaque byte arrays rather than fixed-length constants.

Key Generation: Comparing the APIs

ECDSA key generation derives a public key from a randomly sampled private key via scalar multiplication on the curve. ML-DSA key generation is a lattice-based procedure that produces a public key and private key pair together from a random seed. In liboqs Python, the procedure is straightforward:

import oqs

# ML-DSA-44 key generation
with oqs.Signature("ML-DSA-44") as signer:
    public_key = signer.generate_keypair()
    private_key = signer.export_secret_key()
    print(f"Public key size: {len(public_key)} bytes")   # 1312
    print(f"Private key size: {len(private_key)} bytes") # 2528

Compare this to a typical ECDSA key generation with the Python ecdsa library:

from ecdsa import SigningKey, SECP256k1

sk = SigningKey.generate(curve=SECP256k1)
vk = sk.get_verifying_key()
print(f"Private key: {len(sk.to_string())} bytes")          # 32
print(f"Public key (uncompressed): {len(vk.to_string())} bytes") # 64

The core difference is that ECDSA separates private and public key derivation; you can always recompute the public key from the private key. In ML-DSA, the private key contains pre-computed values that make signing faster, and you should store the full private key rather than re-deriving it. This affects backup and key management logic: an ML-DSA private key is not a 32-byte seed you memorize as a mnemonic. It is a structured 2,528-byte blob that you must encrypt and back up carefully.

Signing and Verification

The signing API in liboqs follows the same pattern as ECDSA: pass a message (or message hash), get back a signature byte string. ML-DSA signs the full message internally rather than requiring you to hash it first. You can pass a transaction hash the same way you would in ECDSA, but you can also pass the raw message and let ML-DSA handle hashing internally (it uses SHAKE-256 internally for ML-DSA-44).

import oqs
import hashlib

# Simulated transaction payload
transaction_data = b"transfer:0xABC...to:0xDEF...amount:1.5"
tx_hash = hashlib.sha256(transaction_data).digest()

with oqs.Signature("ML-DSA-44") as signer:
    public_key = signer.generate_keypair()
    private_key = signer.export_secret_key()
    # Sign the transaction hash
    signature = signer.sign(tx_hash)
    print(f"Signature size: {len(signature)} bytes")  # 2420

# Verification (stateless -- no private key needed)
with oqs.Signature("ML-DSA-44") as verifier:
    is_valid = verifier.verify(tx_hash, signature, public_key)
    print(f"Valid: {is_valid}")  # True

A critical implementation note: ML-DSA is a deterministic signing scheme (the randomness is derived from the private key and message using a pseudorandom function). You do not need to supply an external nonce the way ECDSA does. This eliminates the nonce-reuse vulnerability that has caused real-world ECDSA key recoveries, including various blockchain wallet bugs. If your ECDSA code has nonce-management logic, you can remove it for ML-DSA.

Quick Win

Audit your codebase for any code that generates or manages ECDSA nonces (often labeled k, nonce, or using RFC 6979 deterministic nonce generation). In ML-DSA, this entire code path is eliminated. The signing function handles randomness internally, removing a class of implementation bugs that has caused real private key exposures in ECDSA deployments.

Storage Implications Per Transaction

Every transaction in most blockchain designs includes at least one signature. Upgrading from ECDSA to ML-DSA-44 changes the per-transaction storage footprint significantly:

  • ECDSA signature: 64-71 bytes per transaction
  • ML-DSA-44 signature: 2,420 bytes per transaction (roughly 34x larger)
  • ML-DSA-44 public key (if stored inline): 1,312 bytes per transaction (versus 33 bytes compressed ECDSA)

In practice, many chain designs store public keys separately from transaction data (in an account state trie or identity registry) and only include the signature in the transaction body. In that model, you pay 2,420 bytes per transaction for the signature, not 2,420 + 1,312. You should audit your transaction format to determine whether public keys are included per-transaction or looked up by address.

If your chain has a target transaction size of 300 bytes and currently includes a 64-byte ECDSA signature, your new target needs to accommodate 2,420 bytes for the ML-DSA signature alone. This means either increasing the per-transaction overhead budget, redesigning the transaction format, or using a higher-level construction like batch signature verification where one signature covers multiple operations. For more on how signature size affects throughput, see the breakdown of Dilithium signature sizes and blockchain throughput.

The Case for Hybrid Mode During Transition

Running ECDSA and ML-DSA in parallel during the migration period is the operationally correct way to handle the transition for three concrete reasons:

  1. Backwards compatibility: Existing clients that do not yet support ML-DSA can still verify ECDSA signatures. A hybrid transaction carries both; any client can use the signature scheme it understands.
  2. Defense in depth: If ML-DSA has an unexpected implementation flaw in a new library, the ECDSA signature remains a security backstop until you can patch.
  3. Regulatory and audit requirements: Many enterprise environments require a validation period before decommissioning any security primitive. Running both schemes in parallel satisfies that requirement without delaying the migration start.

A hybrid transaction carries both an ECDSA signature and an ML-DSA signature. The verification rule during the transition period is: accept if both are present and at least one is valid. After the sunset date, the rule becomes: require a valid ML-DSA signature; ECDSA is optional. Eventually, ECDSA is ignored entirely. This gives you a clean three-phase migration with no hard cutover moment.

The storage cost of hybrid mode is approximately 64 + 2,420 = 2,484 bytes per transaction for signatures, versus 64 bytes for ECDSA alone. This is a real cost that you carry during the transition window, which you should plan to run for at least one to two years to allow all wallets and clients to upgrade. For a detailed walkthrough of implementing hybrid signing, see the dedicated post on running ECDSA and ML-DSA together.

Updating Your Build Pipeline

Once you have the signing and verification logic updated, you also need to update:

  • Address derivation: If your chain derives addresses from public keys (as Ethereum and Bitcoin do), you need a new address format or a dual-derivation scheme that maps ML-DSA public keys to addresses. The simplest approach is to hash the ML-DSA public key with SHA-256 or SHA3-256 and truncate to your existing address length. SHA-256 is quantum-safe for pre-image resistance with output sizes above 128 bits, so a 20-byte truncation of a 256-bit hash is borderline; consider moving to 32-byte addresses.
  • Serialization formats: Any code that serializes, deserializes, or validates transaction structure by byte offset will break when signature fields change size. Move to length-prefixed or type-length-value encoding if you are not already using it.
  • Test vectors: NIST provides Known Answer Test (KAT) vectors for ML-DSA. Add these to your CI pipeline to verify that your ML-DSA implementation produces correct output, independent of liboqs. See the CI/CD testing guide for specifics on integrating KAT tests.
  • Hardware wallet support: Most hardware wallets do not yet support ML-DSA natively (as of mid-2026). If your project depends on hardware wallet signing, you will need a software bridging layer or you will need to wait for firmware updates. Track the status with your hardware vendor.

This guide covers the core migration mechanics. The liboqs Python tutorial shows how to install liboqs and implement both ML-DSA and ML-KEM with full code examples. For the background on the NIST standards themselves, see ML-DSA vs CRYSTALS-Dilithium: what changed between the submission and the standard.

Build on a Quantum-Safe Chain

QuanChain is the only blockchain built with post-quantum cryptography at the protocol layer. Explore the developer docs to start building.

View Developer Docs

Frequently Asked Questions

QuanChain Research

Research Division

The QuanChain Research Division investigates post-quantum cryptographic standards, quantum hardware timelines, and blockchain protocol security. Research outputs inform both the QuanChain protocol roadmap and the broader open-source post-quantum blockchain community.

Related Articles