Tracking issue for post-quantum support across this fork and the five upstream forks it depends on. Everything below is scoped as an issue on the repo that owns the code.
Done means: a user installs this extension, creates or restores a Quantus account from a mnemonic, sees it at SS58 prefix 189, and approves a balances.transfer_keep_alive from a dapp that the node accepts and includes in a block. That is #7.
What Quantus actually changes
Only four things differ from stock Substrate. Everything else — extrinsic format v4 signed (0x84), MultiAddress::Id, a standard TxExtension tuple, metadata from an unmodified vendored frame-metadata — is upstream-identical.
Substrate
Quantus
Signature
MultiSignature, 64/65 B
DilithiumSignatureScheme enum — Dilithium87 = variant byte 0, Dilithium65 = variant byte 1; payload is sig ‖ pubkey as a fixed array, no compact length
AccountId
is the 32-byte public key
Poseidon2-over-Goldilocks(pubkey) — one-way, so the public key must ride along inside every signature
Derivation
sr25519 junctions (//hard, /soft)
BIP39 → SLIP-0010-style HMAC-SHA512, master key "Dilithium seed", hardened-only, path m/44'/189189'/<account>'/0'/<0 for 87 | 1 for 65>'
Signing
raw sign over the payload
FIPS-204 pure ML-DSA under context "QUANTUS_EXTRINSIC" (spec ≥ 148; empty context before that)
Sizes, which several issues turn on:
public key
secret key
signature
sig ‖ pk
variant byte
ML-DSA-65
1952
4032
3309
5261
1
ML-DSA-87
2592
4896
4627
7219
0
SS58 prefix is 189. ML-DSA-65 is the scheme new accounts use; ML-DSA-87 is legacy (accounts created before the scheme was recorded).
Sources of truth, in preference order — do not re-derive any of this from prose:
chain:primitives/dilithium-crypto/src/scheme_macro.rs — key generation, signing, IdentifyAccount, the sig ‖ pk layout
chain:primitives/dilithium-crypto/src/signing_context.rs — the context string
quantus-apps:quantus_sdk — a working non-Rust implementation of all of this against a forked polkadart. lib/src/resonance_extrinsic_payload.dart, lib/src/extensions/dilithium_scheme_extension.dart and rust/src/api/crypto.rs are the closest thing to a reference port that exists. Read them before writing anything.
Decode from metadata, never from a name
Standing rule, learned elsewhere and paid for elsewhere. Metadata v14+ carries a complete scale-info registry describing the structure of every call, event and storage item, and state_getMetadata at a block hash makes the node run Metadata_metadata against the runtime wasm in that block's state — the runtime is the oracle and the node executes it for us. blackbeard.observer's blackbeard-core/src/runtime.rs header records why the alternative failed:
An earlier attempt at this problem (qsafe.af) generated pallet and event index tables from the runtime and then hand-wrote a SCALE reader for every event's fields. It worked and it was miserable … transactionVersion went 2 → 3 → 6 across four runtime upgrades, and every one of those is an extrinsic-format change that silently breaks a decoder written against the previous one.
Quantus is a moving target. Wherever a choice exists between reading a type from metadata and pinning it by name, read it from metadata — and cache metadata by spec_version, not by block hash, so decoding survives pruning. This is the deep reason papi needs one patch where polkadot-js needs a fork chain.
The work
quantus/wasm — the crypto itself, compiled from the chain's own crates. Critical path: nothing downstream is testable until it ships something callable.
quantus/wasm#1 — ML-DSA-65/87, Poseidon2 and HD derivation in @polkadot/wasm-crypto
quantus/wasm#2 — golden vectors proving byte-for-byte agreement with the chain
quantus/common — @polkadot/keyring and @polkadot/util-crypto. The bulk of the work.
quantus/common#1 — open the KeypairType union; register prefix 189 in @polkadot/networks
quantus/common#2 — createPair: the ML-DSA arms, and address ≠ public key
quantus/common#3 — backup and restore is impossible as the format stands; land before any build lets a user create an account
quantus/common#4 — BIP44 path derivation in place of suri junctions
quantus/common#5 — thread the FIPS-204 signing context through pair.sign()
#7 — end to end: three tiers of proof, ending in a real transfer in a real block
Standing convention for all six forks
We are not trying to upstream this. Post-quantum PRs to polkadot-js/Parity have been attempted and did not land, and the working assumption is that they never will — this fork only starts mattering to anyone else if and when non-PQ Substrate becomes a victim of a cryptographically relevant quantum computer.
That has a concrete consequence, and it is the opposite of the usual advice: do not contort changes to look upstreamable, but do keep them mechanically rebasable. Prefer additive changes over rewrites, keep Quantus branches in switch/map lookups next to the existing arms rather than restructuring them, and leave the upstream code path byte-identical where a Quantus account is not involved. When we pull a new upstream tag, a git rebase should be boring. Each fork's default branch is main and tracks upstream's master (papi-console is already main); upstream tags are pushed and can be rebased onto.
papi-console is the exception worth stating: it is AGPL-3.0 where the rest are Apache-2.0/MIT, and our change there should stay a pnpm patch rather than a source edit, precisely so that rebasing stays trivial.
Order
extension#5 — Ledger/QR removal, depends on nothing
wasm#1 + wasm#2 together — without vectors, everything below tests that it agrees with itself
extension#7 tier 1 — a node script that submits, before any browser is involved. This is the earliest point at which the chain confirms we got the crypto right
Note what moved: signing a first real extrinsic no longer waits on api#1. papi assembles Quantus extrinsics correctly today, so the console gets there first and the @polkadot/api work becomes about ecosystem reach rather than about proving the thing works.
Tracking issue for post-quantum support across this fork and the five upstream forks it depends on. Everything below is scoped as an issue on the repo that owns the code.
**Done means:** a user installs this extension, creates or restores a Quantus account from a mnemonic, sees it at SS58 prefix 189, and approves a `balances.transfer_keep_alive` from a dapp that the node accepts and includes in a block. That is #7.
## What Quantus actually changes
Only four things differ from stock Substrate. Everything else — extrinsic format v4 signed (`0x84`), `MultiAddress::Id`, a standard `TxExtension` tuple, metadata from an *unmodified* vendored `frame-metadata` — is upstream-identical.
| | Substrate | Quantus |
|---|---|---|
| Signature | `MultiSignature`, 64/65 B | `DilithiumSignatureScheme` enum — `Dilithium87` = variant byte `0`, `Dilithium65` = variant byte `1`; payload is `sig ‖ pubkey` as a **fixed** array, no compact length |
| AccountId | *is* the 32-byte public key | `Poseidon2-over-Goldilocks(pubkey)` — **one-way**, so the public key must ride along inside every signature |
| Derivation | sr25519 junctions (`//hard`, `/soft`) | BIP39 → SLIP-0010-style HMAC-SHA512, master key `"Dilithium seed"`, **hardened-only**, path `m/44'/189189'/<account>'/0'/<0 for 87 \| 1 for 65>'` |
| Signing | raw sign over the payload | FIPS-204 pure ML-DSA under context `"QUANTUS_EXTRINSIC"` (spec ≥ 148; empty context before that) |
Sizes, which several issues turn on:
| | public key | secret key | signature | `sig ‖ pk` | variant byte |
|---|---|---|---|---|---|
| ML-DSA-65 | 1952 | 4032 | 3309 | **5261** | 1 |
| ML-DSA-87 | 2592 | 4896 | 4627 | **7219** | 0 |
SS58 prefix is **189**. ML-DSA-65 is the scheme new accounts use; ML-DSA-87 is legacy (accounts created before the scheme was recorded).
**Sources of truth**, in preference order — do not re-derive any of this from prose:
- `chain:primitives/dilithium-crypto/src/scheme_macro.rs` — key generation, signing, `IdentifyAccount`, the `sig ‖ pk` layout
- `chain:primitives/dilithium-crypto/src/signing_context.rs` — the context string
- `chain:runtime/src/lib.rs` — `Signature`, `Address`, `TxExtension`, `UncheckedExtrinsic`
- `quantus-apps:quantus_sdk` — a **working non-Rust implementation** of all of this against a forked polkadart. `lib/src/resonance_extrinsic_payload.dart`, `lib/src/extensions/dilithium_scheme_extension.dart` and `rust/src/api/crypto.rs` are the closest thing to a reference port that exists. Read them before writing anything.
## Decode from metadata, never from a name
Standing rule, learned elsewhere and paid for elsewhere. Metadata v14+ carries a complete `scale-info` registry describing the *structure* of every call, event and storage item, and `state_getMetadata` at a block hash makes the node run `Metadata_metadata` against the runtime wasm in that block's state — the runtime is the oracle and the node executes it for us. blackbeard.observer's `blackbeard-core/src/runtime.rs` header records why the alternative failed:
> An earlier attempt at this problem (`qsafe.af`) generated pallet and event *index* tables from the runtime and then hand-wrote a SCALE reader for every event's fields. It worked and it was miserable … `transactionVersion` went 2 → 3 → 6 across four runtime upgrades, and every one of those is an extrinsic-format change that silently breaks a decoder written against the previous one.
Quantus is a moving target. Wherever a choice exists between reading a type from metadata and pinning it by name, read it from metadata — and cache metadata by `spec_version`, not by block hash, so decoding survives pruning. This is the deep reason papi needs one patch where polkadot-js needs a fork chain.
## The work
**[quantus/wasm](../../../wasm)** — the crypto itself, compiled from the chain's own crates. Critical path: nothing downstream is testable until it ships something callable.
- quantus/wasm#1 — ML-DSA-65/87, Poseidon2 and HD derivation in `@polkadot/wasm-crypto`
- quantus/wasm#2 — golden vectors proving byte-for-byte agreement with the chain
**[quantus/common](../../../common)** — `@polkadot/keyring` and `@polkadot/util-crypto`. The bulk of the work.
- quantus/common#1 — open the `KeypairType` union; register prefix 189 in `@polkadot/networks`
- quantus/common#2 — `createPair`: the ML-DSA arms, and address ≠ public key
- quantus/common#3 — **backup and restore is impossible as the format stands**; land before any build lets a user create an account
- quantus/common#4 — BIP44 path derivation in place of suri junctions
- quantus/common#5 — thread the FIPS-204 signing context through `pair.sign()`
**[quantus/ui](../../../ui)** — `@polkadot/ui-keyring`.
- quantus/ui#1 — `restoreAccount` cannot rebuild a public key from an address; same problem as common#3, solve it there first
**[quantus/papi-console](../../../papi-console)** — the test console. Independent of the polkadot-js stack entirely, which is what makes it worth having.
- quantus/papi-console#1 — one variant-name whitelist rejects Quantus; a two-line pnpm patch
- quantus/papi-console#2 — audit the rest for hardcoded signature/address assumptions
- quantus/papi-console#3 — chain config, and whether to deploy
**[quantus/api](../../../api)** — `@polkadot/types`. Needed for polkadot-js/apps and the wider `@polkadot/api` ecosystem, but **not** for signing a first real extrinsic.
- quantus/api#1 — the `ExtrinsicSignature` type; note the comment ruling out the `signedTransaction` workaround
**quantus/extension** (here)
- #2 — build against the forks
- #3 — account creation, derivation, default keypair type
- #4 — signing context selection; `sig ‖ pk` for raw bytes
- #5 — retire Ledger and QR (independent, can land first)
- #6 — addresses and metadata at prefix 189
- #7 — **end to end: three tiers of proof, ending in a real transfer in a real block**
## Standing convention for all six forks
We are **not** trying to upstream this. Post-quantum PRs to polkadot-js/Parity have been attempted and did not land, and the working assumption is that they never will — this fork only starts mattering to anyone else if and when non-PQ Substrate becomes a victim of a cryptographically relevant quantum computer.
That has a concrete consequence, and it is the opposite of the usual advice: **do not contort changes to look upstreamable, but do keep them mechanically rebasable.** Prefer additive changes over rewrites, keep Quantus branches in `switch`/map lookups next to the existing arms rather than restructuring them, and leave the upstream code path byte-identical where a Quantus account is not involved. When we pull a new upstream tag, a `git rebase` should be boring. Each fork's default branch is `main` and tracks upstream's `master` (papi-console is already `main`); upstream tags are pushed and can be rebased onto.
papi-console is the exception worth stating: it is AGPL-3.0 where the rest are Apache-2.0/MIT, and our change there should stay a pnpm patch rather than a source edit, precisely so that rebasing stays trivial.
## Order
1. **extension#5** — Ledger/QR removal, depends on nothing
2. **wasm#1 + wasm#2** together — without vectors, everything below tests that it agrees with itself
3. **common#1**, then **common#2, #3, #4, #5**
4. **extension#7 tier 1** — a node script that submits, before any browser is involved. This is the earliest point at which the chain confirms we got the crypto right
5. **ui#1** and **extension#2, #3, #4, #6** in parallel
6. **extension#7 tier 2** — the harness dapp; the everyday loop from here on
7. **papi-console#1, #3**, then **extension#7 tier 3**
8. **api#1**, then polkadot-js/apps for breadth
Note what moved: signing a first real extrinsic no longer waits on api#1. papi assembles Quantus extrinsics correctly today, so the console gets there first and the `@polkadot/api` work becomes about ecosystem reach rather than about proving the thing works.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Tracking issue for post-quantum support across this fork and the five upstream forks it depends on. Everything below is scoped as an issue on the repo that owns the code.
Done means: a user installs this extension, creates or restores a Quantus account from a mnemonic, sees it at SS58 prefix 189, and approves a
balances.transfer_keep_alivefrom a dapp that the node accepts and includes in a block. That is #7.What Quantus actually changes
Only four things differ from stock Substrate. Everything else — extrinsic format v4 signed (
0x84),MultiAddress::Id, a standardTxExtensiontuple, metadata from an unmodified vendoredframe-metadata— is upstream-identical.MultiSignature, 64/65 BDilithiumSignatureSchemeenum —Dilithium87= variant byte0,Dilithium65= variant byte1; payload issig ‖ pubkeyas a fixed array, no compact lengthPoseidon2-over-Goldilocks(pubkey)— one-way, so the public key must ride along inside every signature//hard,/soft)"Dilithium seed", hardened-only, pathm/44'/189189'/<account>'/0'/<0 for 87 | 1 for 65>'"QUANTUS_EXTRINSIC"(spec ≥ 148; empty context before that)Sizes, which several issues turn on:
sig ‖ pkSS58 prefix is 189. ML-DSA-65 is the scheme new accounts use; ML-DSA-87 is legacy (accounts created before the scheme was recorded).
Sources of truth, in preference order — do not re-derive any of this from prose:
chain:primitives/dilithium-crypto/src/scheme_macro.rs— key generation, signing,IdentifyAccount, thesig ‖ pklayoutchain:primitives/dilithium-crypto/src/signing_context.rs— the context stringchain:runtime/src/lib.rs—Signature,Address,TxExtension,UncheckedExtrinsicquantus-apps:quantus_sdk— a working non-Rust implementation of all of this against a forked polkadart.lib/src/resonance_extrinsic_payload.dart,lib/src/extensions/dilithium_scheme_extension.dartandrust/src/api/crypto.rsare the closest thing to a reference port that exists. Read them before writing anything.Decode from metadata, never from a name
Standing rule, learned elsewhere and paid for elsewhere. Metadata v14+ carries a complete
scale-inforegistry describing the structure of every call, event and storage item, andstate_getMetadataat a block hash makes the node runMetadata_metadataagainst the runtime wasm in that block's state — the runtime is the oracle and the node executes it for us. blackbeard.observer'sblackbeard-core/src/runtime.rsheader records why the alternative failed:Quantus is a moving target. Wherever a choice exists between reading a type from metadata and pinning it by name, read it from metadata — and cache metadata by
spec_version, not by block hash, so decoding survives pruning. This is the deep reason papi needs one patch where polkadot-js needs a fork chain.The work
quantus/wasm — the crypto itself, compiled from the chain's own crates. Critical path: nothing downstream is testable until it ships something callable.
@polkadot/wasm-cryptoquantus/common —
@polkadot/keyringand@polkadot/util-crypto. The bulk of the work.KeypairTypeunion; register prefix 189 in@polkadot/networkscreatePair: the ML-DSA arms, and address ≠ public keypair.sign()quantus/ui —
@polkadot/ui-keyring.restoreAccountcannot rebuild a public key from an address; same problem as common#3, solve it there firstquantus/papi-console — the test console. Independent of the polkadot-js stack entirely, which is what makes it worth having.
quantus/api —
@polkadot/types. Needed for polkadot-js/apps and the wider@polkadot/apiecosystem, but not for signing a first real extrinsic.ExtrinsicSignaturetype; note the comment ruling out thesignedTransactionworkaroundquantus/extension (here)
sig ‖ pkfor raw bytesStanding convention for all six forks
We are not trying to upstream this. Post-quantum PRs to polkadot-js/Parity have been attempted and did not land, and the working assumption is that they never will — this fork only starts mattering to anyone else if and when non-PQ Substrate becomes a victim of a cryptographically relevant quantum computer.
That has a concrete consequence, and it is the opposite of the usual advice: do not contort changes to look upstreamable, but do keep them mechanically rebasable. Prefer additive changes over rewrites, keep Quantus branches in
switch/map lookups next to the existing arms rather than restructuring them, and leave the upstream code path byte-identical where a Quantus account is not involved. When we pull a new upstream tag, agit rebaseshould be boring. Each fork's default branch ismainand tracks upstream'smaster(papi-console is alreadymain); upstream tags are pushed and can be rebased onto.papi-console is the exception worth stating: it is AGPL-3.0 where the rest are Apache-2.0/MIT, and our change there should stay a pnpm patch rather than a source edit, precisely so that rebasing stays trivial.
Order
Note what moved: signing a first real extrinsic no longer waits on api#1. papi assembles Quantus extrinsics correctly today, so the console gets there first and the
@polkadot/apiwork becomes about ecosystem reach rather than about proving the thing works.