pq-ethers
v0.1.0
Published
ethers for the post-quantum era: drop-in ethers v6 API for quantum-resistant EVM chains. Wallets, transactions, contracts and EIP-191/712 signing with ML-DSA-87 (FIPS 204 / CRYSTALS-Dilithium) instead of ECDSA.
Maintainers
Readme
pq-ethers
ethers for the post-quantum era. Same API you already know; every signature is ML-DSA-87 (FIPS 204, aka CRYSTALS-Dilithium) instead of ECDSA.
Quantum computers will eventually break secp256k1, the curve every ECDSA chain and
wallet depends on. Natively post-quantum EVM chains already exist today, and they sign
transactions with ML-DSA, the NIST-standardized
lattice signature scheme. That breaks the entire existing tool stack: MetaMask,
ethers.Wallet, web3.js, and every ECDSA signer simply cannot sign for these chains.
pq-ethers is a drop-in, ethers-v6-shaped client for quantum-resistant EVM chains:
create wallets, derive accounts from a mnemonic, sign and send transactions, deploy and
call contracts, sign EIP-191/EIP-712 messages, with the same shapes ethers gives you.
Everything ML-DSA doesn't change (reads, ABI encoding, events, utils) is the real
ethers, re-exported, so you install one package.
npm install pq-ethers # ethers comes with it; you don't install it separatelyWhy pq-ethers
| | What it is | Why it's not enough on its own |
| ---------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| @noble/post-quantum | The ML-DSA math: sign(hash, key) -> bytes | No addresses, transactions, nonces, gas, RPC, or contracts. pq-ethers is the chain client built on top of it, the same way ethers sits on @noble/curves. |
| ethers / web3.js | The EVM chain client everyone knows | ECDSA only. Cannot sign for a chain whose transactions carry ML-DSA signatures. |
| Per-chain web3 forks (e.g. @theqrl/web3) | A full web3.js fork adapted for one PQ chain | Forking the whole client per chain doesn't scale. In pq-ethers a chain's envelope is a one-file plugin (SigScheme), not a fork; the wallet, HD, keystore, and contract layers are shared. |
| Ethereum's own PQ roadmap (EIP-8051 precompile, account abstraction) | PQ signatures verified inside smart-contract wallets on ECDSA chains | Different architecture. It doesn't help chains where ML-DSA is the transaction signature. pq-ethers targets those natively post-quantum chains. |
Highlights
- Familiar DX:
Walletextends ethers'AbstractSigner, so it works anywhere an ethersSigneris accepted. - NIST-standard crypto, zero hand-rolled: ML-DSA-87 via audited-family
@noble/post-quantum, hashing via@noble/hashes, hedged (randomized) signing per FIPS 204. - Full account stack: BIP-39 mnemonics, deterministic ML-DSA HD derivation, encrypted keystore (PBKDF2 + AES-256-GCM), raw key import/export.
- Contracts: deploy, read, and write, including the PQ-safe write path that ethers' own contract plumbing can't provide (see below).
- EIP-191 / EIP-712 signing with standard digests, so dapps hash exactly as they already do.
- Chain-pluggable by design: the per-chain envelope (address derivation, sig-hash, raw-tx layout) is an interface with a registry. Supporting a new PQ chain is one file, in or out of tree.
- Isomorphic: pure TypeScript, no WASM, runs in Node 20.19+ and browsers.
Quick start
import { Wallet, JsonRpcProvider, Contract, deployContract } from 'pq-ethers';
const provider = new JsonRpcProvider(process.env.RPC_URL);
const wallet = new Wallet(process.env.MLDSA_PRIVATE_KEY, provider); // 9792 hex chars
// send value
const tx = await wallet.sendTransaction({ to: '0x…', value: 10n ** 18n });
await tx.wait();
// deploy
const { address, contract } = await deployContract(abi, bytecode, [arg1, arg2], wallet);
// read (plain ethers under the hood - unchanged by ML-DSA)
const c = new Contract(address, abi, provider);
const v = await c.someView();Accounts: create, derive, import
import { Wallet, HDWallet } from 'pq-ethers';
// new wallet with a recoverable 24-word phrase
const w = Wallet.createRandom(provider);
console.log(w.mnemonic, w.address);
// derive multiple accounts from one phrase (ML-DSA HD)
const hd = HDWallet.fromPhrase(w.mnemonic!);
const a0 = hd.deriveAccount(0);
const a1 = hd.deriveAccount(1);
// encrypted keystore (AES-256-GCM) round-trip
const json = await w.encrypt('password');
const loaded = await Wallet.fromEncryptedJson(json, 'password', provider);
// import a raw ML-DSA private key (9792 hex chars)
const imported = new Wallet(process.env.MLDSA_PRIVATE_KEY, provider);ML-DSA keys aren't secp256k1 scalars, so BIP-32/44 paths don't apply. The HD scheme is
HMAC-SHA512(bip39Seed, "mldsa87/account/<i>")[:32] -> ML-DSA keygen; standard BIP-39
in front, domain-separated derivation behind. See
docs/architecture.md for the full rationale.
Why post-quantum signing changes the client (30 seconds)
Two properties of ML-DSA drive everything in this library:
- No key recovery. ECDSA lets the chain recover your public key from a signature
(
ecrecover); ML-DSA mathematically cannot. So every PQ transaction and off-chain signature must carry its public key, and verification returns address-or-null instead of recovering a signer. - Big signatures. An ML-DSA-87 signature is ~4.6 KB and the public key ~2.6 KB
(vs 65 bytes total for ECDSA). ethers' transaction parser rejects them, which is
why writes must go through
pq-ethers' own send path:
import { writeContract } from 'pq-ethers';
// ✅ PQ-safe write: signs and broadcasts through the Wallet
await writeContract(contract, 'transfer', [to, amount]);
// ❌ breaks on PQ chains: ethers wraps this in ContractTransactionResponse,
// whose .wait() parses the signature as 65-byte ECDSA and throws
await contract.transfer(to, amount);Reads are untouched: Contract calls, events, and ABI work exactly as in ethers.
One library, many chains: pluggable schemes
ML-DSA is a universal standard, but each chain invents its own envelope: address
derivation, signing hash, raw-tx layout. That envelope is the only chain-specific part
of pq-ethers, isolated behind the SigScheme interface. Built-in schemes:
| Scheme | Sig-hash | Use when |
| ---------------------------------- | ----------------------------------- | ---------------------------------------------------------- |
| mldsa87Keccak512Scheme (default) | keccak512(keccak256(rlp(...))) | the target chain verifies against the double-hash envelope |
| mldsa87Keccak256Scheme | keccak256(rlp(...)) (single pass) | the target chain verifies a plain EIP-155-style RLP hash |
You select a scheme by passing the scheme object - imported like any other value, type-checked, and tree-shaken if unused:
import { Wallet, mldsa87Keccak256Scheme } from 'pq-ethers';
const wallet = new Wallet(key, provider, mldsa87Keccak256Scheme);Knowing which envelope your chain verifies is your responsibility - the two shipped
schemes produce different signing-hashes for the same chain ID, and there is no way to
detect from the client side which one a node expects. Pick the one that matches your
chain's Sender() implementation and confirm with one real transaction. This is the
permanent contract: pq-ethers will not guess or auto-negotiate the envelope.
Add your chain without forking
SigScheme is public, so a new chain (or a new PQ algorithm, e.g. SLH-DSA) is
implemented entirely from your own repo:
- Implement
SigScheme(name,secretKeyLength,getPublicKey,deriveAddress,computeCreateAddress,signTransaction). Usesrc/tx/schemes/mldsa87-keccak512.tsas the reference. - Pass it wherever a scheme is accepted:
new Wallet(key, provider, myScheme),HDWallet.fromPhrase(phrase, { scheme: myScheme }), etc. - (Optional) If you need to resolve schemes from a runtime string - a name read
from config, or decrypting keystores that reference your scheme by name - call
registerScheme(myScheme)once at startup and resolve withgetScheme(name). Built-in scheme names resolve out of the box (e.g.getScheme(process.env.MLDSA_TX_SCHEME || 'mldsa87-keccak512')); only custom schemes need registering. Note: importinggetSchemebundles both built-in schemes (it must be able to return either at runtime) - passing the object stays fully tree-shakeable.
Wallet, HD, keystore, contracts, and RPC all consume SigScheme generically and keep
working unchanged. This is the difference from the fork-per-chain approach: the
framework is shared; only the envelope is per-chain.
FAQ
Won't Ethereum's own quantum upgrade make this unnecessary? Ethereum's path (ML-DSA precompiles + account abstraction) verifies PQ signatures inside smart-contract wallets while transactions stay ECDSA-compatible. pq-ethers is for chains where ML-DSA is the native transaction signature today. Both worlds will coexist, and pq-ethers re-exports ethers so dual-chain tooling needs only one dependency.
Is the cryptography audited?
pq-ethers adds zero cryptography: signatures come from
@noble/post-quantum and hashing from
@noble/hashes (the same noble family ethers and viem build on). pq-ethers itself, the
chain-integration layer, has not yet been independently audited; review the ~1.1k lines
of src/ yourself, they are written to be read.
Can I verify a pq-ethers transaction independently?
Yes. scripts/live-check.mjs signs against a live node and re-verifies the embedded
ML-DSA signature, and the scheme tests decode every raw tx and verify the signature
against an independently recomputed sig-hash.
Why is my raw transaction ~10 KB? That's ML-DSA: ~4.6 KB signature + ~2.6 KB public key in every transaction (the pubkey must travel with the tx because there is no key recovery). It's the inherent cost of lattice signatures, not overhead added by this library.
Documentation
- docs/api-reference.md: every exported function/class/type,
with full signatures, defaults, and error messages. Read this instead of
src/when integrating. - docs/architecture.md: the rationale: why noble, why a pluggable scheme, why the envelope looks the way it does, and what a new chain needs.
Develop
npm install
npm test # vitest
npm run build # tsup -> dist (ESM + CJS + d.ts)
npm run typecheckSecurity
- All ML-DSA-87 operations come from
@noble/post-quantum; hashing from@noble/hashes. No hand-rolled cryptography anywhere in this package. - Signing is hedged (fresh 32-byte entropy per signature, FIPS 204 randomized variant).
- Keystore: PBKDF2-SHA256 (600k iterations) + AES-256-GCM via WebCrypto.
- Transaction serialization is exercised against a live chain node via
scripts/live-check.mjsand verified structurally in the scheme tests.
To report a vulnerability, please use private disclosure rather than a public issue.
