go-ethereum/docs/mldsa65-precompile-example.md

6.6 KiB

ML-DSA-65 Precompile Contract Integration

This note documents the Solidity-side calling convention for the ML-DSA-65 verification precompile introduced at address 0x0000000000000000000000000000000000000101.

It is intended as an implementation example for smart wallets, account abstraction flows, and EIP companion documentation.

What The Precompile Does

The precompile verifies an ML-DSA-65 signature and returns a single 32-byte word:

  • 0x000...0001 when verification succeeds
  • 0x000...0000 otherwise

It does not revert on invalid signatures.

Call Interface

The precompile does not expose a Solidity ABI in the usual sense. Contracts call it via staticcall with raw bytes.

Input encoding:

input = publicKey || signature || message

For ML-DSA-65, the fixed sizes are:

  • public key: 1952 bytes
  • signature: 3309 bytes
  • message: variable length

Minimal adapter shape:

library MLDSA65 {
    address internal constant PRECOMPILE = address(0x0101);
    uint256 internal constant PUBLIC_KEY_SIZE = 1952;
    uint256 internal constant SIGNATURE_SIZE = 3309;

    function verify(
        bytes memory publicKey,
        bytes memory signature,
        bytes memory message
    ) internal view returns (bool) {
        require(publicKey.length == PUBLIC_KEY_SIZE, "bad ML-DSA public key length");
        require(signature.length == SIGNATURE_SIZE, "bad ML-DSA signature length");

        bytes memory input = bytes.concat(publicKey, signature, message);
        (bool ok, bytes memory out) = PRECOMPILE.staticcall(input);
        if (!ok || out.length != 32) {
            return false;
        }
        uint256 word;
        assembly {
            word := mload(add(out, 0x20))
        }
        return word == 1;
    }
}

Applications should not verify signatures over arbitrary free-form messages. The signed payload should bind the authorization to a specific chain, contract, and action.

At minimum, include:

  • chain ID
  • contract address
  • nonce
  • target address
  • ETH value
  • calldata hash
  • optional deadline

That avoids replay across chains, contracts, or previously authorized actions.

Example Smart Wallet Pattern

The following pattern uses:

  • a stored ML-DSA-65 public key
  • per-wallet nonce replay protection
  • a deterministic signed digest
  • precompile-backed verification before executing a call
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

library MLDSA65 {
    address internal constant PRECOMPILE = address(0x0101);
    uint256 internal constant PUBLIC_KEY_SIZE = 1952;
    uint256 internal constant SIGNATURE_SIZE = 3309;

    function verify(
        bytes memory publicKey,
        bytes memory signature,
        bytes memory message
    ) internal view returns (bool) {
        require(publicKey.length == PUBLIC_KEY_SIZE, "bad ML-DSA public key length");
        require(signature.length == SIGNATURE_SIZE, "bad ML-DSA signature length");

        bytes memory input = bytes.concat(publicKey, signature, message);
        (bool ok, bytes memory out) = PRECOMPILE.staticcall(input);
        if (!ok || out.length != 32) {
            return false;
        }

        uint256 word;
        assembly {
            word := mload(add(out, 0x20))
        }
        return word == 1;
    }
}

contract PQSmartWallet {
    bytes public ownerPublicKey;
    uint256 public nonce;

    event Executed(address indexed target, uint256 value, bytes data, uint256 nonce);
    event PublicKeyRotated(bytes newPublicKey);

    constructor(bytes memory initialPublicKey) {
        require(initialPublicKey.length == MLDSA65.PUBLIC_KEY_SIZE, "bad ML-DSA public key length");
        ownerPublicKey = initialPublicKey;
    }

    function digestForExecute(
        address target,
        uint256 value,
        bytes calldata data,
        uint256 expectedNonce,
        uint256 deadline
    ) public view returns (bytes32) {
        return keccak256(
            abi.encode(
                bytes32("MLDSA65_WALLET_EXECUTE"),
                block.chainid,
                address(this),
                expectedNonce,
                deadline,
                target,
                value,
                keccak256(data)
            )
        );
    }

    function execute(
        address target,
        uint256 value,
        bytes calldata data,
        uint256 deadline,
        bytes calldata signature
    ) external payable {
        require(block.timestamp <= deadline, "signature expired");

        uint256 currentNonce = nonce;
        bytes32 digest = digestForExecute(target, value, data, currentNonce, deadline);
        bytes memory message = abi.encodePacked(digest);

        require(MLDSA65.verify(ownerPublicKey, signature, message), "invalid ML-DSA signature");

        nonce = currentNonce + 1;

        (bool ok, ) = target.call{value: value}(data);
        require(ok, "call failed");

        emit Executed(target, value, data, currentNonce);
    }

    function rotateKey(
        bytes calldata newPublicKey,
        uint256 deadline,
        bytes calldata signature
    ) external {
        require(newPublicKey.length == MLDSA65.PUBLIC_KEY_SIZE, "bad ML-DSA public key length");
        require(block.timestamp <= deadline, "signature expired");

        uint256 currentNonce = nonce;
        bytes32 digest = keccak256(
            abi.encode(
                bytes32("MLDSA65_WALLET_ROTATE"),
                block.chainid,
                address(this),
                currentNonce,
                deadline,
                keccak256(newPublicKey)
            )
        );

        require(
            MLDSA65.verify(ownerPublicKey, signature, abi.encodePacked(digest)),
            "invalid ML-DSA signature"
        );

        ownerPublicKey = newPublicKey;
        nonce = currentNonce + 1;

        emit PublicKeyRotated(newPublicKey);
    }
}

Security Notes

This precompile only provides post-quantum verification as a building block. Whether a contract is quantum-hardened depends on the authorization policy around it.

Good pattern:

  • contract state changes require a valid ML-DSA signature
  • no ECDSA owner bypass exists
  • key rotation is also ML-DSA authorized
  • replay protection is present

Weak pattern:

  • msg.sender remains the real authority
  • ECDSA is accepted as a fallback
  • signed messages omit chain ID, nonce, or contract binding

The security gain is in authorization of state transitions. This does not encrypt storage or make legacy EOAs quantum-safe.

Reference Files

  • Client precompile implementation: core/vm/precompile_mldsa65.go
  • Precompile activation wiring: params/config.go
  • Client-side tests: core/vm/contracts_test.go