Open the KeypairType union and the util-crypto surface to dilithium65/dilithium87 #1

Closed
opened 2026-09-10 10:12:00 +00:00 by grenade · 3 comments
Owner

First of the common changes; #2, #3 and #4 all build on it. Context, sizes and sources of truth are in quantus/extension#1.

The union is closed

packages/util-crypto/src/types.ts:

export type KeypairType = 'ed25519' | 'sr25519' | 'ecdsa' | 'ethereum';

Four string literals, and that type is referenced throughout keyring, ui-keyring, and both extension-base and extension-ui. Adding 'dilithium65' | 'dilithium87' is a one-line change that then surfaces every non-exhaustive switch in the codebase as a type error — which is the point, and is how the rest of the work gets found. Expect the compiler to be the task list.

Two types, not one

Resist collapsing this to a single 'dilithium'. The parameter set is not an implementation detail:

  • it selects the enum variant byte the chain reads off the wire (Dilithium87 = 0, Dilithium65 = 1)
  • it selects the trailing hardened index of the derivation path (…/0' for 87, …/1' for 65)
  • it fixes the key and signature lengths, which is how a stored blob's scheme can be recovered

quantus-apps:quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart is the settled version of these mappings and matches quantus-cli. Copy its conventions, including the storage names ml-dsa-65 / ml-dsa-87, so a wallet exported from one tool imports into the other.

New accounts are ML-DSA-65. ML-DSA-87 is legacy — accounts created before the scheme was recorded — so it must be supported but never chosen.

Also in util-crypto

  • signatureVerify needs to recognise the new prefix bytes. Note it cannot recover a signer from a Quantus signature the way it does elsewhere: verification needs the public key, which for Quantus travels inside the signature as sig ‖ pk. Verifying against an address means hashing the embedded public key with Poseidon2 and comparing — which is exactly what the runtime's Verify::verify does, so mirror it rather than inventing something.
  • Register Quantus in packages/networks at prefix 189, with the genesis hash and token metadata. ui-settings builds its prefix dropdown from this list and the extension's util/chains.ts builds its known-chain list from selectableNetworks, so both light up from this one entry.
  • Whatever mnemonic→seed helper the Quantus path uses must be the 64-byte BIP39 legacy seed. mnemonicToMiniSecret is the Substrate variant and is the default reach in this codebase; using it for Quantus produces keys that are wrong but entirely well-formed, with no error anywhere.
First of the `common` changes; #2, #3 and #4 all build on it. Context, sizes and sources of truth are in quantus/extension#1. ## The union is closed `packages/util-crypto/src/types.ts`: ```ts export type KeypairType = 'ed25519' | 'sr25519' | 'ecdsa' | 'ethereum'; ``` Four string literals, and that type is referenced throughout `keyring`, `ui-keyring`, and both `extension-base` and `extension-ui`. Adding `'dilithium65' | 'dilithium87'` is a one-line change that then surfaces every non-exhaustive `switch` in the codebase as a type error — which is the point, and is how the rest of the work gets found. Expect the compiler to be the task list. ## Two types, not one Resist collapsing this to a single `'dilithium'`. The parameter set is not an implementation detail: - it selects the **enum variant byte** the chain reads off the wire (`Dilithium87` = `0`, `Dilithium65` = `1`) - it selects the **trailing hardened index** of the derivation path (`…/0'` for 87, `…/1'` for 65) - it fixes the key and signature lengths, which is how a stored blob's scheme can be recovered `quantus-apps:quantus_sdk/lib/src/extensions/dilithium_scheme_extension.dart` is the settled version of these mappings and matches `quantus-cli`. Copy its conventions, including the storage names `ml-dsa-65` / `ml-dsa-87`, so a wallet exported from one tool imports into the other. New accounts are ML-DSA-65. ML-DSA-87 is legacy — accounts created before the scheme was recorded — so it must be *supported* but never *chosen*. ## Also in util-crypto - `signatureVerify` needs to recognise the new prefix bytes. Note it cannot recover a signer from a Quantus signature the way it does elsewhere: verification needs the public key, which for Quantus travels *inside* the signature as `sig ‖ pk`. Verifying against an address means hashing the embedded public key with Poseidon2 and comparing — which is exactly what the runtime's `Verify::verify` does, so mirror it rather than inventing something. - Register Quantus in `packages/networks` at **prefix 189**, with the genesis hash and token metadata. `ui-settings` builds its prefix dropdown from this list and the extension's `util/chains.ts` builds its known-chain list from `selectableNetworks`, so both light up from this one entry. - Whatever mnemonic→seed helper the Quantus path uses must be the **64-byte BIP39 legacy seed**. `mnemonicToMiniSecret` is the Substrate variant and is the default reach in this codebase; using it for Quantus produces keys that are wrong but entirely well-formed, with no error anywhere.
Author
Owner

Done. Branch quantus-keypair-types, one commit. Full build green, 3026 tests pass, lint clean.

The union, and the worklist it produced

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.

Network registration

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.

Testnets are first-class here

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)

Three upstream tests now assert different rules

Rewritten rather than deleted, each saying what this fork believes instead:

  1. genesis entries for testnets — allowed, for ones we opted into
  2. "has no testnets exposed""labels every exposed testnet as a testnet", plus a second test that we expose no testnet we did not opt into. Losing the label is the failure that would matter: a user cannot tell play money from real money by looking at an address.
  3. "has no ss58 duplicates" — one network per prefix stops holding the moment a chain has a testnet. Quantus mainnet and Heisenberg are both 189 because they are the same chain at two deployments and an address is valid on either; giving one a prefix it does not use would print addresses no Quantus tool recognises. Consumers key on genesisHash — the extension's chains.ts takes genesisHash[0] — so sharing a prefix costs nothing there.

Downstream effects worth knowing

  • extension#6 gets its prefix-189 rendering and both chains in the built-in list for free, via 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.
  • The ESM-only decision in @quantus/crypto will meet this repo's dual ESM/CJS build at #2. That is the one place it could bite; flagged there.
Done. Branch `quantus-keypair-types`, one commit. Full build green, 3026 tests pass, lint clean. ## The union, and the worklist it produced `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: ```ts 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. ## Network registration 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. ## Testnets are first-class here 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) ``` ## Three upstream tests now assert different rules Rewritten rather than deleted, each saying what this fork believes instead: 1. **genesis entries for testnets** — allowed, for ones we opted into 2. **"has no testnets exposed"** → **"labels every exposed testnet as a testnet"**, plus a second test that we expose no testnet we did not opt into. Losing the *label* is the failure that would matter: a user cannot tell play money from real money by looking at an address. 3. **"has no ss58 duplicates"** — one network per prefix stops holding the moment a chain has a testnet. Quantus mainnet and Heisenberg are both 189 because they are the same chain at two deployments and an address is valid on either; giving one a prefix it does not use would print addresses no Quantus tool recognises. Consumers key on `genesisHash` — the extension's `chains.ts` takes `genesisHash[0]` — so sharing a prefix costs nothing there. ## Downstream effects worth knowing - extension#6 gets its prefix-189 rendering and both chains in the built-in list for free, via `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. - The ESM-only decision in `@quantus/crypto` will meet this repo's dual ESM/CJS build at #2. That is the one place it could bite; flagged there.
Author
Owner

Checked 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).
  • The two schemes stay distinct everywhere it matters:
    • variant byte: 87 = 0, 65 = 1;
    • path: the last index selects the scheme;
    • storage names: ml-dsa-65 and ml-dsa-87.
  • Quantus is registered at prefix 189, with genesis and token.
  • Mnemonic seeding uses the 64-byte BIP39 seed through 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.

Checked 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`). - The two schemes stay distinct everywhere it matters: - variant byte: 87 = 0, 65 = 1; - path: the last index selects the scheme; - storage names: `ml-dsa-65` and `ml-dsa-87`. - Quantus is registered at prefix 189, with genesis and token. - Mnemonic seeding uses the 64-byte BIP39 seed through `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.
Author
Owner

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:

  • two types, not one
  • variant bytes 87→0 and 65→1
  • derivation paths ending …/0' for 87 and …/1' for 65
  • new accounts default to ML-DSA-65
  • Quantus registered at prefix 189
  • the BIP39 64-byte seed

Now on main at 0480bfcbc.

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: - two types, not one - variant bytes 87→0 and 65→1 - derivation paths ending `…/0'` for 87 and `…/1'` for 65 - new accounts default to ML-DSA-65 - Quantus registered at prefix 189 - the BIP39 64-byte seed Now on `main` at `0480bfcbc`.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/common#1