pqc-ethers
v1.0.1
Published
Client library for Quorum blockchain with Post-Quantum Cryptography (PQC) and Zero-Knowledge Proof (ZK) support
Maintainers
Readme
pqc-ethers
Client library for Quorum blockchain with Post-Quantum Cryptography (PQC) and Zero-Knowledge Proof (ZK) support. Uses ethers v5 and @noble/post-quantum (ML-DSA-65 / FIPS 204) for PQC signing.
Features
- Post-Quantum Cryptography: ML-DSA-65 (Dilithium) via
@noble/post-quantum; signing uses context"ML-DSA-65"to match Quorum Go node verification - Hybrid Signatures: ECDSA + PQC on the same transaction (Legacy only)
- Unified transaction classes:
ECDSATransaction,PQCTransaction,HybridTransaction— transaction type (Legacy / AccessList / DynamicFee) is inferred from fields (accessList,maxFeePerGas/maxPriorityFeePerGas) - Transaction types: Legacy (0), AccessList / EIP-2930 (1), DynamicFee / EIP-1559 (2)
- Providers: HTTP (
QuorumProvider) and WebSocket (QuorumWebSocketProvider);createQuorumProvider(url)picks by URL scheme - ERC20: Simplified interface for token transfers (ECDSA, PQC, Hybrid)
- ZK: Support for ZK transactions (Groth16, PLONK, STARK)
- RLP: Canonical RLP encoding compatible with Go Quorum node; AccessList encoded as
[[addressHex, storageKeysHex[]], ...]
Installation
npm install pqc-ethersDependencies
ethers@^5.7.2— Ethereum library@noble/post-quantum@^0.4.0— ML-DSA (FIPS 204) for PQC signatures
Quick Start
CommonJS
const { QuorumProvider, ECDSAWallet, PQCWallet, HybridWallet, createQuorumProvider } = require('pqc-ethers');
const provider = new QuorumProvider('http://localhost:8545');
const ecdsaWallet = new ECDSAWallet('0x...', provider);
const pqcWallet = await PQCWallet.create();
const hybridWallet = await HybridWallet.create(ecdsaWallet);
const hybridAddress = await hybridWallet.getAddress();ES Modules
import { QuorumProvider, ECDSAWallet, PQCWallet, createQuorumProvider } from 'pqc-ethers';
const provider = new QuorumProvider('http://localhost:8545');
const pqcWallet = await PQCWallet.create();
const wsProvider = createQuorumProvider('ws://localhost:8546');Providers
HTTP and WebSocket
const { QuorumProvider, QuorumWebSocketProvider, createQuorumProvider } = require('pqc-ethers');
const httpProvider = new QuorumProvider('http://localhost:8545');
const wsProvider = createQuorumProvider('ws://localhost:8546');
if (wsProvider instanceof QuorumWebSocketProvider) {
const block = await wsProvider.getBlockNumber();
await wsProvider.destroy();
}- QuorumProvider: HTTP RPC
- QuorumWebSocketProvider: WebSocket RPC (subclass of
ethers.providers.WebSocketProvider) - createQuorumProvider(url): Returns
QuorumProviderforhttp(s):andQuorumWebSocketProviderforws(s):
Wallets
ECDSA Wallet
Standard Ethereum wallet (ECDSA).
const { ECDSAWallet } = require('pqc-ethers');
const wallet = new ECDSAWallet('0x...', provider);
console.log(wallet.address);
const sig = await wallet.signMessage('Hello');PQC Wallet
Post-quantum wallet using ML-DSA-65. Key generation is asynchronous.
const { PQCWallet } = require('pqc-ethers');
const wallet = await PQCWallet.create();
const address = await wallet.getAddress();
const hash = new Uint8Array(32);
const signature = await wallet.sign(hash);
const ok = await wallet.verify(hash, signature);
const walletFromKeys = PQCWallet.fromSecretKey(secretKey, publicKey);- PQCWallet.create(): Returns
Promise<PQCWallet>with keys already initialized - sign(hash): Signs the 32-byte hash with context
"ML-DSA-65"(matches Go node) - verify(hash, signature): Verifies using the same context
- fromSecretKey(secretKey, publicKey): Build wallet from existing ML-DSA-65 keys
Hybrid Wallet
ECDSA + PQC; supports Legacy transactions only.
const { HybridWallet, ECDSAWallet, PQCWallet } = require('pqc-ethers');
const ecdsaWallet = new ECDSAWallet('0x...');
const hybridWallet = await HybridWallet.create(ecdsaWallet);
const address = await hybridWallet.getAddress();
const hybridFromKeys = HybridWallet.fromKeys(ecdsaPrivateKey, pqcSecretKey, pqcPublicKey);- HybridWallet.create(ecdsaWallet): Accepts
ECDSAWalletor ECDSA private key string; generates PQC keys internally
Transactions
Transaction type is inferred from params:
- Legacy (0): default when no
accessList/ nomaxFeePerGas - AccessList (1): when
accessListis present and non-empty (ortype === 1) - DynamicFee (2): when both
maxFeePerGasandmaxPriorityFeePerGasare set (ortype === 2)
Use unified classes ECDSATransaction, PQCTransaction, or aliases: LegacyTransaction, PQCLegacyTransaction, AccessListTransaction, PQCAccessListTransaction, DynamicFeeTransaction, PQCDynamicFeeTransaction.
ECDSA — Legacy (Type 0)
const { ECDSATransaction, LegacyTransaction, TX_TYPE, ethers } = require('pqc-ethers');
const tx = new LegacyTransaction({
chainId: 1337,
nonce: 0,
gasPrice: await provider.getGasPrice(),
gasLimit: 21000n,
to: '0x...',
value: ethers.utils.parseEther('1.0'),
});
await tx.sign(ecdsaWallet);
const hex = tx.getTxType() === TX_TYPE.LEGACY ? ethers.utils.hexlify(tx.serialize()) : tx.signedHex;
const txHash = await provider.sendRawTransaction(hex);ECDSA — AccessList (Type 1)
const { ECDSATransaction, TX_TYPE, ethers } = require('pqc-ethers');
const gasPriceBn = await provider.getGasPrice();
const tx = new ECDSATransaction({
chainId: 1337,
nonce: await provider.getTransactionCount(ecdsaWallet.address, 'pending'),
gasPrice: BigInt(gasPriceBn.toString()),
gasLimit: 21000n,
to: '0x...',
value: ethers.utils.parseEther('0.001'),
accessList: [{ address: '0x...', storageKeys: [] }],
});
await tx.sign(ecdsaWallet);
const hex = tx.getTxType() === TX_TYPE.LEGACY ? ethers.utils.hexlify(tx.serialize()) : tx.signedHex;
await provider.sendRawTransaction(hex);ECDSA — DynamicFee (Type 2, EIP-1559)
const { ECDSATransaction, TX_TYPE, ethers } = require('pqc-ethers');
const gasPriceBn = await provider.getGasPrice();
const gasPrice = BigInt(gasPriceBn.toString());
const tx = new ECDSATransaction({
chainId: 1337,
nonce: await provider.getTransactionCount(ecdsaWallet.address, 'pending'),
maxPriorityFeePerGas: gasPrice,
maxFeePerGas: gasPrice * 2n,
gasLimit: 21000n,
to: '0x...',
value: ethers.utils.parseEther('0.001'),
});
await tx.sign(ecdsaWallet);
const hex = tx.getTxType() === TX_TYPE.LEGACY ? ethers.utils.hexlify(tx.serialize()) : tx.signedHex;
await provider.sendRawTransaction(hex);Note: In ethers v5, provider.getGasPrice() returns a BigNumber. When passing into fields used as BigInt (e.g. maxFeePerGas), use BigInt(gasPriceBn.toString()) to avoid "Cannot mix BigInt and other types".
PQC — Legacy (Type 0)
const { PQCTransaction, PQCLegacyTransaction, ethers } = require('pqc-ethers');
const nonce = await provider.getTransactionCount(await pqcWallet.getAddress(), 'pending');
const gasPriceBn = await provider.getGasPrice();
const tx = new PQCLegacyTransaction({
chainId: 1337,
nonce,
gasPrice: BigInt(gasPriceBn.toString()),
gasLimit: 21000n,
to: '0x...',
value: ethers.utils.parseEther('0.001'),
});
await tx.sign(pqcWallet);
const txHash = await provider.sendRawTransaction(tx.getHex());PQC — AccessList (Type 1)
const { PQCTransaction, ethers } = require('pqc-ethers');
const tx = new PQCTransaction({
chainId: 1337,
nonce: await provider.getTransactionCount(await pqcWallet.getAddress(), 'pending'),
gasPrice: BigInt((await provider.getGasPrice()).toString()),
gasLimit: 21000n,
to: '0x...',
value: ethers.utils.parseEther('0.001'),
accessList: [{ address: '0x...', storageKeys: [] }],
});
await tx.sign(pqcWallet);
await provider.sendRawTransaction(tx.getHex());AccessList must be an array of { address, storageKeys }. The library converts it to RLP-encodable form internally.
PQC — DynamicFee (Type 2)
const { PQCTransaction, ethers } = require('pqc-ethers');
const gasPriceBn = await provider.getGasPrice();
const gasPrice = BigInt(gasPriceBn.toString());
const tx = new PQCTransaction({
chainId: 1337,
nonce: await provider.getTransactionCount(await pqcWallet.getAddress(), 'pending'),
maxPriorityFeePerGas: gasPrice,
maxFeePerGas: gasPrice * 2n,
gasLimit: 21000n,
to: '0x...',
value: ethers.utils.parseEther('0.001'),
});
await tx.sign(pqcWallet);
await provider.sendRawTransaction(tx.getHex());Hybrid — Legacy only (Type 0)
Hybrid (ECDSA + PQC) supports Legacy only.
const { HybridTransaction, HybridLegacyTransaction, ethers } = require('pqc-ethers');
const tx = new HybridLegacyTransaction({
chainId: 1337,
nonce: await provider.getTransactionCount(await hybridWallet.getAddress(), 'pending'),
gasPrice: BigInt((await provider.getGasPrice()).toString()),
gasLimit: 21000n,
to: '0x...',
value: ethers.utils.parseEther('0.001'),
});
await tx.sign(hybridWallet);
await provider.sendRawTransaction(tx.getHex());Signing uses the same hash and ML-DSA-65 context as the Go node so verification succeeds.
ERC20 Tokens
const { ERC20Token } = require('pqc-ethers');
const token = new ERC20Token(tokenAddress, provider, ecdsaWallet);
const tx = await token.transfer(recipientAddress, amount, { gasPrice, gasLimit });
const txHashPQC = await token.transferPQC(pqcWallet, provider, recipientAddress, amount, { chainId, gasPrice: 0n, gasLimit });
const txHashHybrid = await token.transferHybrid(hybridWallet, provider, recipientAddress, amount, { chainId, gasPrice: 0n, gasLimit });Zero-Knowledge Transactions
const { ZKTransaction, ZK_PROOF_SYSTEM } = require('pqc-ethers');
const zkTx = new ZKTransaction({
chainId: 1337,
nonce: 0,
gasPrice: 0n,
gasLimit: 21000,
to: '0x...',
value: ethers.utils.parseEther('1.0'),
zkProofSystem: ZK_PROOF_SYSTEM.GROTH16,
zkProof: proofBytes,
zkPublicInputs: publicInputs,
zkVerificationKeyHash: vkHash,
});
const txHash = await zkTx.send(provider, senderAddress);Constants
const {
TX_TYPE,
PQC_TYPE,
ZK_PROOF_SYSTEM,
DEFAULT_CHAIN_ID,
} = require('pqc-ethers');
TX_TYPE.LEGACY // 0
TX_TYPE.ACCESS_LIST // 1
TX_TYPE.DYNAMIC_FEE // 2
TX_TYPE.ZK_PRIVATE // 4
PQC_TYPE.NONE // 0
PQC_TYPE.DILITHIUM // 1
ZK_PROOF_SYSTEM.GROTH16 // 1
ZK_PROOF_SYSTEM.PLONK // 2
ZK_PROOF_SYSTEM.STARK // 3
DEFAULT_CHAIN_ID // 1337Utilities
const {
derivePQCAddress,
deriveHybridAddress,
isValidAddress,
encodeUint64,
encodeBigInt,
encodeSignature,
ethers,
} = require('pqc-ethers');
const address = derivePQCAddress(publicKey);
const hybridAddress = deriveHybridAddress(ecdsaPublicKey, pqcPublicKey);
const ok = isValidAddress('0x...');
const nonceHex = encodeUint64(5);
const valueHex = encodeBigInt(ethers.utils.parseEther('1.0'));
const sigHex = encodeSignature('0x...');Example script
The examples/send-coin.js script demonstrates:
- Provider tests (HTTP, WebSocket via
createQuorumProvider) - ECDSA —
wallet.sendTransaction() - ECDSA — Legacy (unified
ECDSATransaction) - PQC — Legacy
- Hybrid — Legacy
- ECDSA — AccessList (type 1)
- ECDSA — DynamicFee (type 2)
- PQC — AccessList (type 1)
- PQC — DynamicFee (type 2)
- WebSocket send
Run with:
RPC_URL=http://your-node:8545 node examples/send-coin.jsOptional env: RPC_URL, WS_URL, CHAIN_ID, ECDSA_PRIVATE_KEY, RECIPIENT, AMOUNT, RPC_TIMEOUT_MS.
Notes
- PQC algorithm: ML-DSA-65 (FIPS 204) via
@noble/post-quantum; context"ML-DSA-65"is used for sign/verify so that client signatures verify on the Quorum Go node. - Async PQC keys: Use
await PQCWallet.create()orawait wallet._initPromisebefore using address/sign. - BigInt vs BigNumber: For ethers v5,
getGasPrice()returns BigNumber; useBigInt(gasPriceBn.toString())when settinggasPrice/maxFeePerGas/maxPriorityFeePerGasin transaction params. - RLP: Integer encoding is canonical (no leading zero bytes); AccessList is encoded as
[[addressHex, storageKeysHex[]], ...]for type 1/2. - Hybrid: Only Legacy (type 0) is supported; do not set
accessListormaxFeePerGasfor Hybrid.
License
LGPL-3.0
