Various useful utility functions that are used across all projects in the @polkadot namespace. It provides utility functions with additional safety checks, allowing not only for consistent coding, but also reducing the general boilerplate.
This repository is split up into a number of internal packages, namely utilities -
Contributions are welcome!
To start off, this repo (along with others in the @polkadot family) uses yarn workspaces to organise the code. As such, after cloning, its dependencies should be installed via yarn, not via npm; the latter will result in broken dependencies.
To get started -
git clone https://github.com/polkadot-js/common <optional local path>yarnyarn run buildyarn vuepress dev docsLooking for tutorials to get started? Look at examples for guides on how to use the base utilities.
106 activities
Closing as done, and superseded by #6. This issue asked to open the union to dilithium65 | dilithium87. #6 went further and closed it to only those two, so the classical types are gone rather than kept alongside.
The rest of this issue shipped in the quantus.2 and quantus.3 forks:
…/0' for 87 and …/1' for 65Now on main at 0480bfcbc.
Done. Merged to main in all three repos, fast-forwarded from pq-only:
0480bfcbc: KeypairType = 'dilithium65' | 'dilithium87'. The classical primitives are deleted: ed25519, sr25519, secp256k1, signature, the ethereum and ledger HD paths, hdkd and mnemonicToMiniSecret. keyring.createFromJson and createFromUri refuse classical types with "<type> keys are not quantum-safe and cannot be held here…". Published as 14.0.3-quantus.3.160d97c1: ui-keyring.restoreAccount refuses version-0 JSON and classical types. Published as 3.16.7-quantus.3.29033e8b: depends on both. The derive-from-parent flow, extension-compat-metamask and the Ethereum address branches are removed. The restore screen now shows the keyring's refusal reason instead of "Invalid Json file".Verification:
.spec.tsx files still don't run; see earlier comments.Address decoding is untouched, so a pasted Polkadot address still renders and can be recognised.
wednesday, september 16, 2026 — 19:15:36 utc0480bfc build: packageInfo at 14.0.3-quantus.3, as publisheda5157ef build: version the forks 14.0.3-quantus.3, on @quantus/crypto 0.3.0e1d88c6 feat!: post-quantum only: remove ed25519, sr25519, ecdsa and ethereum keys0480bfc build: packageInfo at 14.0.3-quantus.3, as publishedChecked against main (7f23d0a60). The library side is done, but nothing uses it yet, and it has one gap of its own.
Done (e55352aa4, 74e1e0cc6):
Remaining:
dilithiumFromCliWallet returns a key without checking it against the file's address. That check is a separate helper, cliWalletAddressMatches, which a caller has to remember to call, and the comment near cliWallet.ts:194 wrongly implies it is already done. Refuse a mismatch inside the import.dilithiumFromCliWallet, so a user cannot import a quantus-cli wallet file today. It needs:
Correction: earlier today I listed this issue as done. It isn't.
wednesday, september 16, 2026 — 17:51:43 utcChecked against main (7f23d0a60). One criterion is unmet, and it is better settled in #6 than here.
Met:
KeypairType includes dilithium65 and dilithium87 (util-crypto/src/types.ts).ml-dsa-65 and ml-dsa-87.dilithiumPairFromMnemonic, not mnemonicToMiniSecret.Unmet: signatureVerify does not recognise ML-DSA. util-crypto/src/signature/verify.ts is still upstream's. It only accepts 65- and 66-byte signatures and throws "Unknown crypto type" for Quantus ones. dilithiumVerify mirrors the runtime instead (Poseidon2 account check, then verify). It is a separate function because an ML-DSA verification needs a FIPS-204 context, and signatureVerify(message, signature, address) has nowhere to put one.
Plan: resolve this in #6. When the classical schemes go, signatureVerify either takes a context and routes to dilithiumVerify, or is removed in favour of it; either way nothing will silently call a verifier that cannot succeed. Closing this issue once that lands.
Closing. The goal is met; the one unmet criterion moves to quantus/wasm#2.
Met on main (f7dc72e32):
quantus-cli for a given mnemonic and index (dilithiumDerive.spec.ts);derive() on an existing pair refuses with a message that says why.In the extension, account indices are added from a wallet's menu (quantus/extension#14), using the same //<n> → m/44'/189189'/n'/0'/scheme' path.
Moved: "matches the hdwallet crate's own golden vectors" needs the shared fixture that quantus/wasm#2 has not produced yet. It is tracked there (item 5) rather than keeping this open.
wednesday, september 16, 2026 — 17:51:16 utcVerified against main (7f23d0a60), closing.
SignOptions.context exists, and an ML-DSA pair refuses to sign without one (pair/index.ts, with a spec).contextForSpec(specVersion), and quantus/extension#4 showed chain acceptance under spec 148 and rejection under the empty context.@quantus/crypto.Small gaps, not worth holding this open for:
The curve pairs go away in quantus/common#6 anyway.
wednesday, september 16, 2026 — 17:51:12 utcVerified against main (7f23d0a60), closing.
createPair: landed in 2bffbc62b and 506b77351.quantus-cli: for a known mnemonic, both schemes (dilithiumDerive.spec.ts); the raw-seed crystal_alice is checked in dilithium.spec.ts.sign(…, { withType: true }) carries the chain's variant byte and length, and verifies with dilithiumVerify. Chain acceptance was then shown through quantus/extension#7 tier 1.pair/index.spec.ts is untouched, the curve sign functions are wrapped so a context can't reach them, and sr25519 is pinned.decodePkcs8 now decides by key type rather than by key length.The classical arms themselves go in quantus/common#6.
wednesday, september 16, 2026 — 17:51:08 utc7f23d0a fix: gate quantum-safe chains in the wallet, not in the network list49f3442 build: version the forked packages 14.0.3-quantus.1, not upstream's 14.0.3b39241c build: take @quantus/crypto from the Gitea registry, not a symlink74e1e0c feat(util-crypto): export quantus-cli wallet filese55352a feat(util-crypto): import quantus-cli wallet files7f23d0a fix: gate quantum-safe chains in the wallet, not in the network list49f3442 build: version the forked packages 14.0.3-quantus.1, not upstream's 14.0.3b39241c build: take @quantus/crypto from the Gitea registry, not a symlink74e1e0c feat(util-crypto): export quantus-cli wallet filese55352a feat(util-crypto): import quantus-cli wallet filesCorrecting reason (1) in the comment above — it was overstated, and the record should not carry it as written.
Checked rather than assumed:
encodePair receives { publicKey, secretKey } only. The encrypted blob is PAIR_HDR ‖ secretKey ‖ PAIR_DIV ‖ publicKey. There is no slot for a mnemonic.accountsCreateSuri passes meta { genesisHash, name }. For a root account the suri is the mnemonic, and it is dropped once addUri has derived from it.extension-base/src/stores/ persists a seed or mnemonic.One thing worth knowing while in here: pairToJson places meta outside the encrypted field, so meta is plaintext at rest. derivationCreate stores suri in meta for derived accounts — a path like //0, not a mnemonic, but it is worth remembering that meta is not protected.
I wrote "it would import the mnemonic into extension storage" as though that were a property of CLI-file import. It is not. An importer can derive the key and discard the mnemonic, which is precisely what the mnemonic-paste path does today. That reason described an implementation I had assumed rather than a constraint of the format.
The narrower point that does hold: an ML-DSA secret key compromises one account, while a mnemonic regenerates the whole tree — every index, both schemes, and the wormhole tree at m/44'/189189189'/…. So a stored mnemonic would be the same password guarding a much larger prize. That is a reason to build an importer carefully, not a reason to refuse one.
Still: do not implement CLI-format import yet. But on cost and stability, not on principle:
encryption_version: 2 with empty kyber_ciphertext/kyber_public_key — an ML-KEM mode planned or optional. Implementing against a format mid-flight means tracking it.If someone asks for it, there is no principled objection. Build it derive-and-discard, storing only what createFromUri would have produced from the same mnemonic.
Everything else in the comment above stands: the formats share nothing, mnemonic portability is verified against real CLI wallets, and the export direction remains the gap that can actually lose funds.
tuesday, september 15, 2026 — 06:27:21 utcSettled. The two formats have nothing in common, and no round-trip is possible without implementing a second encryption container — but it turns out not to matter, for a reason worth writing down.
quantus-cli actually writesDecrypted a real wallet from ~/.quantus/wallets/ (CLI 2.2.2) to check rather than infer. The outer container:
{ "name": "crystal_alice",
"address": "qzk1Nxai…",
"wallet_type": "hot",
"encrypted_data": [ …26528 bytes… ],
"kyber_ciphertext": [], "kyber_public_key": [],
"argon2_salt": [ …16 bytes… ],
"argon2_params": "$argon2id$v=19$m=19456,t=2,p=1$…",
"aes_nonce": [ …12 bytes… ],
"encryption_version": 2,
"created_at": "2026-09-10T10:50:25Z" }
Argon2id → AES-256-GCM. polkadot-js is scrypt → NaCl secretbox (xsalsa20-poly1305), N=2^17, r=8, p=1. Different KDF, different cipher, different container. Not a variant of each other.
The plaintext inside is JSON, not PKCS8:
{ "name": "…",
"keypair": { "public_key": [ …2592… ], "private_key": [ …4896… ], "scheme": "ml-dsa-87" },
"mnemonic": "…",
"derivation_path": "m/44'/189189'/0'/0'/0'",
"metadata": { "version": "1.0.0", "algorithm": "ML-DSA-87" } }
Two details worth having:
scheme values are ml-dsa-65 / ml-dsa-87 — exactly what SCHEME_NAME in @quantus/crypto already uses, so that mapping was rightThe mnemonic is right there, and quantus wallet export --format mnemonic hands it over. So a CLI wallet already moves into the extension — by mnemonic, which this branch supports and which is now verified against real CLI wallets rather than argued:
| CLI wallet | scheme | how | result |
|---|---|---|---|
| hd_ml_dsa_65 | ml-dsa-65 | mnemonic → //0 | address and public key match byte-for-byte |
| hd_ml_dsa_87 | ml-dsa-87 | mnemonic → //0 | match |
| crystal_alice | ml-dsa-87 | raw hex seed (no mnemonic stored) | match |
Note the third: dev wallets store derivation_path: "m/" and no mnemonic, so they move as a hex seed — which is why createFromUri taking a raw seed underived (quantus/common#4) matters beyond the genesis accounts.
It is buildable — @noble/hashes has Argon2id and WebCrypto has AES-GCM — but:
encryption_version: 2, with kyber_ciphertext and kyber_public_key present but empty — an ML-KEM envelope mode that is either planned or optional. Chasing a format mid-flight for a path the mnemonic already covers is a poor trade.Revisit if someone actually asks. Reopening this would be cheap; unpicking a half-supported second container later would not be.
quantus-cli cannot read our JSON either, and that is the direction that can lose funds. An extension backup is readable only by polkadot-js-format tooling — so if a user's sole backup is the extension's JSON export and the extension is unavailable, the CLI is no help.
The fix is UX, not format: the extension's backup flow must offer the mnemonic at least as prominently as the JSON file, because the mnemonic is the artifact every Quantus tool can read. Filed against quantus/extension#3.
Closing this issue — the format question is answered and the compatibility that matters is verified.
tuesday, september 15, 2026 — 06:19:04 utcDone, on quantus-keypair-types (f7dc72e32). 12 specs.
This issue left the choice open. What landed:
| form | meaning |
|---|---|
| (nothing) | account 0 — what quantus-cli defaults to |
| //<n> | account index n |
| //m/44'/189189'/… | that path verbatim, for anything the index form cannot express |
| anything else | throws |
//Alice throws, /0 throws, and an unhardened level inside an explicit path throws. Quietly reinterpreting any of them would hand back an address no other Quantus tool derives and no seed phrase obviously recovers — which is the failure this issue asked to avoid, and it is worth more than the convenience of accepting the input.
quantus/extension#3 should send //<n>, or an index it turns into one.
The ML-DSA branch sits before the existing seeding, not after. That existing code calls mnemonicToMiniSecret, Substrate's variant and the default reach in this file; using it would produce a perfectly well-formed key for an account nobody owns, with nothing to indicate anything went wrong. The ML-DSA path goes mnemonic → 64-byte BIP39 seed → HMAC-SHA512 keyed with "Dilithium seed", via the hdwallet crate.
Taken underived, since that is how the dev-genesis accounts are defined — 0x0000…00 gives crystal_alice. Combining a raw seed with a derivation path is refused rather than guessed: whether such a seed is the master or already derived is unanswerable, and picking one silently would be the same class of mistake as the seeding above.
derive() on an existing pairThe last question here — "does something defensible, or refuses". It refuses, with its own message rather than keyFromPath's generic one, because the reason is specific and actionable: a child would have to come from this pair's key material, and ML-DSA keys are not derivable from one another at all. The Quantus tree derives each account from the mnemonic independently. So the error names createFromUri and the mnemonic.
That makes quantus/extension#3's canDerive decision easy: derive-from-account cannot work, and the UI should offer an account index at creation instead.
quantus-cli for both schemes from the public dev phrasederive() refuses, with a message saying what to do instead//Alice, //Bob pinned)Not done: golden vectors from quantus/wasm#2 — these fixtures are CLI-derived, which is an independent implementation but not the crate's own published vectors.
tuesday, september 15, 2026 — 05:56:57 utcDone, on quantus-keypair-types (506b77351). 7 new specs; 3060 tests pass repo-wide.
decodePair now takes an optional secretLength. It located the divider by trying 64 then 32 — the two lengths every curve scheme uses — and an ML-DSA secret is 4032 or 4896, so neither matched.
The caller passes the length rather than this function searching for PAIR_DIV. Searching was tempting and is wrong: the divider is five bytes, so a 4032-byte secret contains a false match roughly once in 270 million keys, and the result would be a silently wrong key rather than an error. The caller knows the type, so it knows the length. The public key is read as the remainder, since its length varies too and the body ends there.
createFromJson no longer invents a public key from the address. PairInfo gains an optional accountId, and the ordering problem this issue identified is exactly what it solves: a restored pair is locked, callers read pair.address off it long before a password appears, and for ML-DSA there is nothing to compute that from until decodePkcs8 opens the blob. So the account id is carried as data and cleared once the real key arrives.
Third one, found on the way: decodePkcs8 decided "secret key or seed?" by length. A 4032-byte ML-DSA secret took the seed branch and was fed to keygen as entropy — producing a valid, completely wrong key, in silence. Now type-aware.
decodePkcs8 checks the derived account id against the carried one and throws if they differ. That is not defensive padding:
For every curve scheme, a JSON file with an edited address field simply fails to decode — the address is the public key. Here it decodes perfectly and yields a pair reporting an address its key does not control. A user would see someone else's address in their own wallet and believe they held it. Pinned by a test that tampers exactly that field.
This issue asked whether the format can round-trip with quantus-cli and the mobile wallet, both of which record a scheme field. I have not checked, and nothing here depends on the answer — the format used is upstream's own, with encoding.content[1] carrying dilithium65/dilithium87 as the crypto type, which is where upstream already puts it.
If those tools write something else, this will not import their wallets. That is worth settling before the extension ships, and it is a smaller change now than later. Leaving this issue open on that point alone.
tuesday, september 15, 2026 — 05:56:40 utcf7dc72e feat(keyring,util-crypto): BIP44 derivation for ML-DSA, in place of junctions506b773 fix(keyring): make account backup and restore work for ML-DSA pairs2bffbc6 feat(keyring,util-crypto): ML-DSA arms for createPair, alongside the curve onesDeferred, deliberately. Both halves of this — the type narrowing and the primitive deletion — wait until post-quantum signing is proven end to end.
i'd be inclined to rip out the non-pq support but it doesn't feel like the first thing we should do since it's bound to slow down the 'prove we can do pq in these forks' story chasing compilation issues dealing with the removed code/support.
The work this issue describes produces no post-quantum capability whatsoever. Every hour of it is spent fixing code that has nothing to do with ML-DSA — 30 spec files, the hw-ledger packages, whatever else falls out — while the question that actually carries risk stays unanswered.
And that question is genuinely open. quantus/wasm#1 proved the crypto agrees with the chain, but nothing has yet proved that a signature built through the keyring, wrapped by @polkadot/types, and submitted to a node is accepted. The failure modes there are the nasty ones: a valid signature under the wrong FIPS-204 context, or a framing error that re-frames every byte after the signature. Those are found by quantus/extension#7 tier 1, and nothing about deleting sr25519 brings them forward.
Worse, doing the removal first would blur the diagnosis. If the first real extrinsic is rejected, the useful question is "is our ML-DSA wrong, or our extrinsic assembly wrong". A branch that has simultaneously torn out half of util-crypto adds a third candidate that is pure noise.
KeypairType, delete util-crypto/src/{ed25519,sr25519,secp256k1,ethereum}, and add the runtime refusals at the JSON entry points.The intent is unchanged — the forks end up post-quantum only, primitives deleted rather than merely unreachable. What changed is that it happens once there is something working to protect, rather than as a prerequisite to having one.
assertSubstrateType now has a clearer job than "not implemented yet": it is the seam that lets the two schemes coexist through the proving phase. Each call site is replaced by a real ML-DSA arm in #2, and the helper itself is deleted here, when the classical arms go. Its remaining call sites are the progress bar.
c616a0d feat(networks): offer only chains whose accounts are post-quantumDone. Branch quantus-keypair-types, one commit. Full build green, 3026 tests pass, lint clean.
KeypairType now carries dilithium65 and dilithium87. As predicted, the compiler enumerated the rest — nine sites across three files, and they are exactly the four TYPE_* maps #2 describes plus keyFromPath (#4) and PairFromSeed in keyring.ts.
Rather than stub each one, there is a single narrowing helper, assertSubstrateType(type, what) in util-crypto/src/dilithium.ts. Call sites read:
const raw = TYPE_ADDRESS[assertSubstrateType(type, 'Deriving an address')](publicKey);
It throws rather than degrading. Every one of these paths produces key material or an address, and a wrong-but-plausible value means funds at an address nobody controls, discovered later and unrecoverably. Each call site disappears as #2 and #4 fill in the real arms — grep for assertSubstrateType to see what is left.
keyFromPath throws for the same reason, and it is worth being explicit about why it is not simply a no-op: silently ignoring a derivation path hands the caller the parent key under a child's name.
Quantus is at prefix 189, appended to the registry rather than merged — @substrate/ss58-registry does not carry it and nothing occupies 185..195, so there is nothing to override, and appending means a registry bump cannot silently drop it.
standardAccount: 'ml-dsa' needed KnownSubstrate widened, since upstream's type is a closed union of the curves Substrate ships. It is load-bearing rather than cosmetic: a missing standardAccount makes toExpanded mark the network isIgnored.
Data provenance, since guessing any of it would be silent and wrong:
| | value | source |
|---|---|---|
| prefix | 189 | quantus_sdk AppConstants.ss58prefix, and quantus-cli |
| symbol / decimals | QTC / 12 | chain/node/src/chain_spec.rs and AppConstants — two independent sources |
| mainnet genesis | 0xfb5487c0…626fba | blackbeard.observer config.toml, read from the node with chain_getBlockHash(0) on 2026-09-09, its launch day |
| Heisenberg genesis | 0xa5aa9e5c…c8d3b4f | same |
That config also records the trap: the staging chain is 0xb0e90ed9f291a9194f79… and telemetry calls it "Quantus Staging Mainnet", so a name-based guess would have picked the wrong chain.
I initially followed upstream's rule and left Heisenberg out — knownGenesis states outright that it "does not and should not contain any testnets", and defaults.spec.ts enforces it. That was wrong for this fork, and @grenade corrected it mid-change: our tools have to reach testnets, and where the forked source is arbitrarily broken for that we fix the fork.
So isIgnored no longer follows from isTestnet. But not by deleting the rule: doing that exposed all fifteen of upstream's testnets too, which moved the tail of the sorted list from ZERO to ZERO Alphaville and would have surprised anyone reading availableNetworks for reasons unrelated to Quantus. It is an opt-in allowlist, exposedTestnet, one network at a time. isTestnet is still reported honestly — we stop deciding for the consumer, we do not stop telling them.
Result:
selectable at 189: quantus, quantus-heisenberg (testnet)
upstream testnets exposed: 0
sorted tail: ZERO (unchanged)
Rewritten rather than deleted, each saying what this fork believes instead:
genesisHash — the extension's chains.ts takes genesisHash[0] — so sharing a prefix costs nothing there.selectableNetworks.@quantus/crypto is not wired in yet. #2 is where TYPE_FROM_SEED / TYPE_ADDRESS / TYPE_SIGNATURE / TYPE_PREFIX start calling it, and it should read sizes from ext_mldsa_sizes rather than hardcoding 1952/5261/7219 — see quantus/wasm#1.@quantus/crypto will meet this repo's dual ESM/CJS build at #2. That is the one place it could bite; flagged there.