Blocks everything else. See quantus/extension#1 for the full context, sizes and sources of truth.
Why here, and not a fresh package
The hard part of shipping this crypto to a browser extension is not the maths — it is the packaging. @polkadot/wasm-crypto already solves exactly that problem: the wasm is base64-inlined into the JS rather than fetched, init is synchronous, there is an asm.js fallback, and the whole thing runs inside an MV3 service worker under script-src 'self' 'wasm-unsafe-eval' (which both extension manifests already set). Rebuilding that pipeline in a new repo means re-solving a solved problem and then keeping two of them working.
The counter-argument is real and worth recording: this couples us to upstream's Rust build, which we do not control. If that turns out to hurt more than the packaging saved, splitting @quantus/crypto out is a reasonable reversal — but start here.
These are the crates the runtime itself uses, published on crates.io, and quantus-apps:quantus_sdk/rust/Cargo.toml already pins a working set of versions for a non-Rust consumer — copy that pinning. A hand-port of Poseidon2 (~2450 lines of Goldilocks field arithmetic and round constants) would be a second implementation to keep in step with the chain forever, which is the trap blackbeard.observer's CLAUDE.md describes for SS58.
Surface to export
Keep names and shapes in the style of the existing @polkadot/wasm-crypto exports so the common side reads like the sr25519/ed25519 arms next to it.
mldsaKeypairFromSeed(seed: [u8;32], scheme) -> { publicKey, secretKey } — this is FIPS-204 KeyGen_internal(ξ) verbatim; keypair_var in the dilithium crate does SHAKE256(ξ ‖ k ‖ ℓ) with no Quantus-specific twist
mldsaSign(secretKey, publicKey, message, ctx) -> sig — deterministic (the chain passes hedge: None); ctx is the FIPS-204 context, prefixed as [0, |ctx|, ctx…]
mldsaVerify(publicKey, message, sig, ctx) -> bool
poseidonHashBytes(bytes) -> [u8;32] — qp_poseidon_core::hash_bytes, the account-id derivation
Tempting to do the derivation in JS — it is only BIP39 plus HMAC-SHA512 — but it is the same trap. The master key is the literal string "Dilithium seed", the path is hardened-only, and BIP39 seeding must be the 64-byte legacy seed, not mnemonicToMiniSecret (the Substrate variant, which polkadot-js reaches for by default and which would silently produce wrong keys that look perfectly valid). One implementation, in the crate that the chain and the mobile wallet already agree with.
Acceptance
poseidonHashBytes of a known public key equals the AccountId32 the chain derives for it
a keypair derived at m/44'/189189'/0'/0'/1' matches the golden vectors (#2)
a signature made under "QUANTUS_EXTRINSIC" verifies with the Rust verify_ml_dsa_65, and does not verify under the empty context
loads and runs inside an MV3 service worker — not just in a page
bundle size delta is recorded in the PR
That last two are risks, not formalities. ML-DSA is not small, and @polkadot/wasm-crypto inlines its wasm as base64 into a JS file that a service worker has to parse on every cold start. If the delta turns out to be unacceptable, say so on this issue before working around it — lazy-loading or splitting the Quantus wasm into its own artifact is a design change, not an implementation detail.
Blocks everything else. See quantus/extension#1 for the full context, sizes and sources of truth.
## Why here, and not a fresh package
The hard part of shipping this crypto to a browser extension is not the maths — it is the packaging. `@polkadot/wasm-crypto` already solves exactly that problem: the wasm is base64-inlined into the JS rather than fetched, init is synchronous, there is an asm.js fallback, and the whole thing runs inside an MV3 service worker under `script-src 'self' 'wasm-unsafe-eval'` (which both extension manifests already set). Rebuilding that pipeline in a new repo means re-solving a solved problem and then keeping two of them working.
The counter-argument is real and worth recording: this couples us to upstream's Rust build, which we do not control. If that turns out to hurt more than the packaging saved, splitting `@quantus/crypto` out is a reasonable reversal — but start here.
## Wrap the chain's crates, do not reimplement
Vendor as dependencies, not as ports:
- `qp-rusty-crystals-dilithium` (features `ml-dsa-65`, `ml-dsa-87`)
- `qp-poseidon-core`
- `qp-rusty-crystals-hdwallet` (features `ml-dsa-65`, `ml-dsa-87`)
These are the crates the runtime itself uses, published on crates.io, and `quantus-apps:quantus_sdk/rust/Cargo.toml` already pins a working set of versions for a non-Rust consumer — copy that pinning. A hand-port of Poseidon2 (~2450 lines of Goldilocks field arithmetic and round constants) would be a second implementation to keep in step with the chain forever, which is the trap blackbeard.observer's `CLAUDE.md` describes for SS58.
## Surface to export
Keep names and shapes in the style of the existing `@polkadot/wasm-crypto` exports so the `common` side reads like the sr25519/ed25519 arms next to it.
- `mldsaKeypairFromSeed(seed: [u8;32], scheme) -> { publicKey, secretKey }` — this is FIPS-204 `KeyGen_internal(ξ)` verbatim; `keypair_var` in the dilithium crate does `SHAKE256(ξ ‖ k ‖ ℓ)` with no Quantus-specific twist
- `mldsaSign(secretKey, publicKey, message, ctx) -> sig` — deterministic (the chain passes `hedge: None`); `ctx` is the FIPS-204 context, prefixed as `[0, |ctx|, ctx…]`
- `mldsaVerify(publicKey, message, sig, ctx) -> bool`
- `poseidonHashBytes(bytes) -> [u8;32]` — `qp_poseidon_core::hash_bytes`, the account-id derivation
- `mldsaDeriveFromMnemonic(mnemonic, password, path, scheme) -> { publicKey, secretKey }`
## Put HD derivation in wasm too
Tempting to do the derivation in JS — it is only BIP39 plus HMAC-SHA512 — but it is the same trap. The master key is the literal string `"Dilithium seed"`, the path is hardened-only, and BIP39 seeding must be the **64-byte legacy seed**, not `mnemonicToMiniSecret` (the Substrate variant, which polkadot-js reaches for by default and which would silently produce wrong keys that look perfectly valid). One implementation, in the crate that the chain and the mobile wallet already agree with.
## Acceptance
- [ ] `poseidonHashBytes` of a known public key equals the `AccountId32` the chain derives for it
- [ ] a keypair derived at `m/44'/189189'/0'/0'/1'` matches the golden vectors (#2)
- [ ] a signature made under `"QUANTUS_EXTRINSIC"` verifies with the Rust `verify_ml_dsa_65`, and does **not** verify under the empty context
- [ ] loads and runs inside an MV3 service worker — not just in a page
- [ ] bundle size delta is recorded in the PR
That last two are risks, not formalities. ML-DSA is not small, and `@polkadot/wasm-crypto` inlines its wasm as base64 into a JS file that a service worker has to parse on every cold start. If the delta turns out to be unacceptable, say so on this issue before working around it — lazy-loading or splitting the Quantus wasm into its own artifact is a design change, not an implementation detail.
The crate does not declare #![feature(inline_const)], so it will not parse on a 2022 nightly. The chain itself pins 1.93.0. These are four years apart and not reconcilable.
Bumping upstream's toolchain is not the answer either: wasm-crypto's dependency set is 2019-era (schnorrkel 0.9.1, ed25519-dalek 1.0.0-pre.4, curve25519-dalek 2.1.0, rand 0.7, sha2 0.8, wasm-bindgen =0.2.79). Modernising it would mean rewriting the existing sr25519/ed25519 crypto build, which is exactly what the rebasability convention in quantus/extension#1 forbids.
2. Bridge cannot drive a modern wasm-bindgen either
@polkadot/wasm-bridge's Bridge<C> is pleasingly generic, and reusing it was part of the appeal. But it implements wasm-bindgen 0.2.79's ABI — a JS-side #heap array with manual slot management. wasm-bindgen 0.2.128 uses externref tables (__wbindgen_init_externref_table, __wbindgen_externref_table_grow), which Wbg does not provide.
We do not need it. Our whole surface is byte-slices in, bytes or bool out — no JsValue, no js-sys. wasm-bindgen's own generated glue plus initSync({ module: bytes }) covers it in far less code than adapting Bridge, and initSync takes a BufferSource directly, so there is no fetch and nothing that upsets the extension CSP.
3. Size: not a problem, and the numbers are better than expected
The issue asked for this to be recorded, and flagged it as a possible design-changing risk. It is not:
wasm raw
zlib
base64 in JS
upstream @polkadot/wasm-crypto-wasm 7.5.4
335,277
168,782
225,044
Quantus: ML-DSA-65 + 87 + Poseidon2 + HD
333,004
128,259
171,012
The entire Quantus crypto surface is the same size as upstream's whole crypto blob, and smaller compressed — and ours is measured beforewasm-opt -Oz, where upstream's is after. Roughly doubling the extension's inlined wasm payload, ~225 KB → ~396 KB of base64. No lazy-loading, no splitting, no redesign needed.
4. It builds
Clean on stable rustc 1.98 for wasm32-unknown-unknown with wasm-bindgen 0.2.128, no getrandom js feature required — the qp crates take entropy as a parameter and sign deterministically, so nothing reaches for a system RNG.
Revised plan
A second package with its own Cargo crate, in this repo, rather than new files inside wasm-crypto:
packages/quantus-crypto — its own Cargo.toml, edition 2021, modern wasm-bindgen, built with the chain's toolchain rather than upstream's nightly
wasm-crypto and its Cargo graph stay byte-identical to upstream, so rebasing stays boring
reuse what is actually reusable: scripts/pack-wasm-base.mjs (zlib + base64 embedding) and @polkadot/wasm-util (base64 decode, fflate inflate)
skip Bridge; use wasm-bindgen's generated glue with initSync
skip the asm.js fallback.wasm2js over ML-DSA would be enormous and slow, and both extension manifests already set wasm-unsafe-eval, so wasm is always available where we need it. If a non-wasm target ever matters, that is a separate decision with a real cost attached.
This keeps the reason we chose this repo — the packaging pipeline and the CSP-safe init — while dropping the part that turned out to be unshareable. The counter-argument recorded in the issue (coupling to a build we do not control) is now half-answered: we are coupled to their packaging, not their compiler.
Naming follows the chain's own variant bytes: scheme 0 = ML-DSA-87, 1 = ML-DSA-65, so the selector threaded through this API is the same number that ends up on the wire, and TYPE_PREFIX downstream in quantus/common#2 is the identity function on it.
Investigated before building. The plan survives, but two of its assumptions do not — and the risk it flagged turns out not to be one.
## 1. The crates cannot share upstream's Cargo build
`scripts/rust-version.sh` pins `RUST_VER=nightly-2022-06-24`, built via `xargo build -Z build-std`. `qp-rusty-crystals-dilithium` uses inline `const { … }` blocks — stable since Rust 1.79 (June 2024) — in five files:
```
src/packing.rs src/rounding.rs src/polyvec.rs src/sign.rs src/poly.rs
```
```rust
const {
assert!(PK == params::publickeybytes(K));
assert!(SK == params::secretkeybytes(K, L, ETA));
}
```
The crate does not declare `#![feature(inline_const)]`, so it will not parse on a 2022 nightly. The chain itself pins `1.93.0`. These are four years apart and not reconcilable.
Bumping upstream's toolchain is not the answer either: `wasm-crypto`'s dependency set is 2019-era (`schnorrkel` 0.9.1, `ed25519-dalek` 1.0.0-pre.4, `curve25519-dalek` 2.1.0, `rand` 0.7, `sha2` 0.8, `wasm-bindgen` =0.2.79). Modernising it would mean rewriting the *existing* sr25519/ed25519 crypto build, which is exactly what the rebasability convention in quantus/extension#1 forbids.
## 2. `Bridge` cannot drive a modern wasm-bindgen either
`@polkadot/wasm-bridge`'s `Bridge<C>` is pleasingly generic, and reusing it was part of the appeal. But it implements wasm-bindgen 0.2.79's ABI — a JS-side `#heap` array with manual slot management. wasm-bindgen 0.2.128 uses **externref tables** (`__wbindgen_init_externref_table`, `__wbindgen_externref_table_grow`), which `Wbg` does not provide.
We do not need it. Our whole surface is byte-slices in, bytes or bool out — no `JsValue`, no js-sys. wasm-bindgen's own generated glue plus `initSync({ module: bytes })` covers it in far less code than adapting `Bridge`, and `initSync` takes a `BufferSource` directly, so there is no `fetch` and nothing that upsets the extension CSP.
## 3. Size: not a problem, and the numbers are better than expected
The issue asked for this to be recorded, and flagged it as a possible design-changing risk. It is not:
| | wasm raw | zlib | base64 in JS |
|---|---:|---:|---:|
| upstream `@polkadot/wasm-crypto-wasm` 7.5.4 | 335,277 | 168,782 | 225,044 |
| Quantus: ML-DSA-65 + 87 + Poseidon2 + HD | 333,004 | 128,259 | 171,012 |
The entire Quantus crypto surface is the same size as upstream's whole crypto blob, and *smaller* compressed — and ours is measured **before** `wasm-opt -Oz`, where upstream's is after. Roughly doubling the extension's inlined wasm payload, ~225 KB → ~396 KB of base64. No lazy-loading, no splitting, no redesign needed.
## 4. It builds
Clean on stable rustc 1.98 for `wasm32-unknown-unknown` with wasm-bindgen 0.2.128, no `getrandom` js feature required — the qp crates take entropy as a parameter and sign deterministically, so nothing reaches for a system RNG.
## Revised plan
A **second package with its own Cargo crate**, in this repo, rather than new files inside `wasm-crypto`:
- `packages/quantus-crypto` — its own `Cargo.toml`, edition 2021, modern wasm-bindgen, built with the chain's toolchain rather than upstream's nightly
- `wasm-crypto` and its Cargo graph stay **byte-identical to upstream**, so rebasing stays boring
- reuse what is actually reusable: `scripts/pack-wasm-base.mjs` (zlib + base64 embedding) and `@polkadot/wasm-util` (base64 decode, fflate inflate)
- skip `Bridge`; use wasm-bindgen's generated glue with `initSync`
- **skip the asm.js fallback.** `wasm2js` over ML-DSA would be enormous and slow, and both extension manifests already set `wasm-unsafe-eval`, so wasm is always available where we need it. If a non-wasm target ever matters, that is a separate decision with a real cost attached.
This keeps the reason we chose this repo — the packaging pipeline and the CSP-safe init — while dropping the part that turned out to be unshareable. The counter-argument recorded in the issue (coupling to a build we do not control) is now half-answered: we are coupled to their *packaging*, not their *compiler*.
Naming follows the chain's own variant bytes: scheme `0` = ML-DSA-87, `1` = ML-DSA-65, so the selector threaded through this API is the same number that ends up on the wire, and `TYPE_PREFIX` downstream in quantus/common#2 is the identity function on it.
Branch quantus-crypto pushed — three commits. The crate is done and verified; the packaging is written and its runtime behaviour proven, but the ESM/CJS build has not been exercised.
What is done
packages/quantus-crypto — its own crate, wasm-crypto untouched. Exports ext_mldsa_sizes, ext_mldsa_is_scheme, ext_mldsa_from_seed, ext_mldsa_sign, ext_mldsa_verify, ext_poseidon_hash, ext_mldsa_derive, following upstream's ext_<family>_<op> convention.
Sizes are exported, not hardcoded.ext_mldsa_sizes(scheme) returns [public, secret, signature, signature_with_public]. These are consensus-critical — the runtime decodes a fixed-size array with no compact length prefix — and a JS constant that drifted from the crate would re-frame every byte after the signature while looking healthy. quantus/common#2 should call this rather than writing 1952/5261/7219 down anywhere.
Logic split from the #[wasm_bindgen] wrappers. Discovered the hard way: JsError cannot be constructed on a non-wasm target — it panics with "cannot call wasm-bindgen imported functions on non-wasm targets" — so every function returning one was untestable by cargo test, and the error paths are what most needs testing. Each module now has a plain -> Result<_, String> body with a wrapper that only translates.
Verified against an independent oracle
Not against our own output. The quantus CLI 2.2.2 is installed locally, and its developer create-test-wallets and wallet import were the reference:
HD derivation, ML-DSA-65 at m/44'/189189'/0'/0'/1'
matches
HD derivation, ML-DSA-87 at m/44'/189189'/0'/0'/0'
matches
signature verifies under QUANTUS_EXTRINSIC
yes
same signature under the empty context
rejected — the boundary is detectable
signing determinism
identical bytes across runs
sizes
1952/4032/3309/5261 and 2592/4896/4627/7219
10 cargo test cases pin all of it, plus unhardened-path rejection, mismatched key halves, over-long context, and that a BIP39 passphrase changes the result while "" means "none".
The HD fixture is the public Substrate dev phrase, deliberately — the mnemonics the CLI generated for me are real keys and do not belong in a public repo, whereas that phrase is in polkadot-sdk, polkadot-js and every tutorial, so pinning it commits no secret.
The JS path is proven, the JS build is not
base64 → inflate → initSync({ module }) was run against the real build output: no fetch anywhere, crystal_alice's account id correct through the JS boundary, sig ‖ pk the right length, and JsError surfacing as a normal JS exception.
What has not run is polkadot-dev-build-ts — the ESM/CJS dual emit, the exports map, rollup. That needs yarn install in this repo, which I have not done. One snag already surfaced and is fixed: bytes.js is CJS while the package is "type": "module", so the CJS copy needs its own {"type":"commonjs"} directory marker, as upstream does for wasm-crypto-wasm.
Also not run: wasm-opt -Oz, since binaryen is not fetched. Every size figure here is therefore an upper bound.
Build
scripts/build-quantus.sh, separate from build-wasm.sh on purpose, and install-build-deps.sh now fetches a second wasm-bindgen (0.2.128) into bindgen-quantus/ — the two ABIs cannot share a binary. No asm.js step, per the reasoning above.
Remaining before this closes
run yarn install and polkadot-dev-build-ts; fix whatever the dual-module build objects to
run the full build-quantus.sh including wasm-opt, and record the real (post-opt) size
load it in an MV3 service worker — the acceptance criterion that is genuinely untested, since node is not a service worker
wire cargo test for this crate into the repo's test script, which currently only runs wasm-crypto's
#2's golden vectors — the fixtures here are a good start but are CLI-derived, not the crate's own published vectors
Branch `quantus-crypto` pushed — three commits. The crate is done and verified; the packaging is written and its runtime behaviour proven, but the ESM/CJS build has not been exercised.
## What is done
**`packages/quantus-crypto`** — its own crate, `wasm-crypto` untouched. Exports `ext_mldsa_sizes`, `ext_mldsa_is_scheme`, `ext_mldsa_from_seed`, `ext_mldsa_sign`, `ext_mldsa_verify`, `ext_poseidon_hash`, `ext_mldsa_derive`, following upstream's `ext_<family>_<op>` convention.
**Sizes are exported, not hardcoded.** `ext_mldsa_sizes(scheme)` returns `[public, secret, signature, signature_with_public]`. These are consensus-critical — the runtime decodes a fixed-size array with no compact length prefix — and a JS constant that drifted from the crate would re-frame every byte after the signature while looking healthy. quantus/common#2 should call this rather than writing 1952/5261/7219 down anywhere.
**Logic split from the `#[wasm_bindgen]` wrappers.** Discovered the hard way: `JsError` cannot be constructed on a non-wasm target — it panics with *"cannot call wasm-bindgen imported functions on non-wasm targets"* — so every function returning one was untestable by `cargo test`, and the error paths are what most needs testing. Each module now has a plain `-> Result<_, String>` body with a wrapper that only translates.
## Verified against an independent oracle
Not against our own output. The `quantus` CLI 2.2.2 is installed locally, and its `developer create-test-wallets` and `wallet import` were the reference:
| check | result |
|---|---|
| `crystal_alice` / `dilithium_bob` / `crystal_charlie` account ids | match the CLI's SS58 addresses at prefix 189 |
| HD derivation, ML-DSA-65 at `m/44'/189189'/0'/0'/1'` | matches |
| HD derivation, ML-DSA-87 at `m/44'/189189'/0'/0'/0'` | matches |
| signature verifies under `QUANTUS_EXTRINSIC` | yes |
| same signature under the empty context | **rejected** — the boundary is detectable |
| signing determinism | identical bytes across runs |
| sizes | 1952/4032/3309/**5261** and 2592/4896/4627/**7219** |
10 `cargo test` cases pin all of it, plus unhardened-path rejection, mismatched key halves, over-long context, and that a BIP39 passphrase changes the result while `""` means "none".
The HD fixture is the **public Substrate dev phrase**, deliberately — the mnemonics the CLI generated for me are real keys and do not belong in a public repo, whereas that phrase is in polkadot-sdk, polkadot-js and every tutorial, so pinning it commits no secret.
## The JS path is proven, the JS *build* is not
`base64 → inflate → initSync({ module })` was run against the real build output: no `fetch` anywhere, `crystal_alice`'s account id correct through the JS boundary, `sig ‖ pk` the right length, and `JsError` surfacing as a normal JS exception.
What has **not** run is `polkadot-dev-build-ts` — the ESM/CJS dual emit, the `exports` map, rollup. That needs `yarn install` in this repo, which I have not done. One snag already surfaced and is fixed: `bytes.js` is CJS while the package is `"type": "module"`, so the CJS copy needs its own `{"type":"commonjs"}` directory marker, as upstream does for `wasm-crypto-wasm`.
Also not run: `wasm-opt -Oz`, since binaryen is not fetched. Every size figure here is therefore an **upper bound**.
## Build
`scripts/build-quantus.sh`, separate from `build-wasm.sh` on purpose, and `install-build-deps.sh` now fetches a second wasm-bindgen (0.2.128) into `bindgen-quantus/` — the two ABIs cannot share a binary. No asm.js step, per the reasoning above.
## Remaining before this closes
- [ ] run `yarn install` and `polkadot-dev-build-ts`; fix whatever the dual-module build objects to
- [ ] run the full `build-quantus.sh` including `wasm-opt`, and record the real (post-opt) size
- [ ] load it in an MV3 service worker — the acceptance criterion that is genuinely untested, since node is not a service worker
- [ ] wire `cargo test` for this crate into the repo's `test` script, which currently only runs `wasm-crypto`'s
- [ ] #2's golden vectors — the fixtures here are a good start but are CLI-derived, not the crate's own published vectors
Packaging done. @quantus/crypto builds, installs and has been consumed as a package. Every checkbox from the previous comment is closed except #2's vectors.
Two more toolchain faults, same shape as the first
polkadot-dev-build-ts will not build a non-@polkadot/* package. It returns early in buildJsand when collecting locals for import rewriting. Renaming into someone else's scope to satisfy a string check would be worse than not using it, and nothing is lost — this package needs no deno variant, no rollup bundle, no cross-package import rewriting. scripts/build-quantus-js.sh is a plain tsc build, which also keeps yarn build:js byte-identical to upstream's behaviour.
binaryen 105 silently corrupts the wasm. This one is worth remembering. Upstream pins version_105 (2021), which predates the externref tables wasm-bindgen 0.2.128 emits. wasm-opt -Oz "optimises" the table into something that fails at instantiation:
WebAssembly.Table.grow(): failed to grow table by 4
The wasm is valid before wasm-opt and broken after. Every cargo test still passes. Nothing in the build says a word. It only surfaces when a consumer calls initWasm(). install-build-deps.sh now fetches binaryen 123 into binaryen-quantus/, exactly as it does the second wasm-bindgen — three tools now, all for the same underlying reason.
It was the consumer test that caught it, which is why that test stages a real node_modules layout instead of importing from build/ in place. In this repo node_modules/@polkadot/wasm-util symlinks to the package source, which carries no exports map, so a deep import resolves for a real consumer and fails here for reasons unrelated to our package. Staging tests module resolution too, which is half of what can break in a published package.
Also: the @polkadot/wasm-util import is now deep (/base64, /fflate) rather than via the package index — the index re-exports packageDetect, whose only job is a side effect registering with @polkadot/util, a peer dependency we would otherwise inherit for nothing.
ESM only. The consumers are ESM and the wasm-bindgen glue is ESM-only, so a CJS variant means a second generated glue or hand-written marshalling. Revisit if quantus/common's CJS build needs it — that is the one place this could bite.
Real size, post-wasm-opt
wasm raw
zlib
base64 in JS
upstream @polkadot/wasm-crypto-wasm 7.5.4
335,277
168,782
225,044
@quantus/crypto
234,292
109,649
146,200
Smaller than upstream's entire crypto blob on every axis.
Cold start: 9 ms
test/probe/ holds a module Worker served under the exact extension_pages CSP from both manifests. A module Worker has no window and no document — the property that matters, since an MV3 service worker has neither either. On Firefox:
hasDOM: false hasWindow: false
initWasm: ok (9.0 ms cold)
keygen: 1.0 ms
account: matches quantus-cli
sign: 3.0 ms (4627 bytes)
verify: 1.0 ms ok
ctx sep: ok (rejected under spec-147 ctx)
9 ms to base64-decode 146 KB, inflate to 234 KB and instantiate — paid on every service-worker wake, and not a problem. Those are ML-DSA-87 timings, the larger set; 65 is cheaper. The CSP is genuinely enforced: an earlier version of the page used an inline script and Firefox blocked it.
A real MV3 extension probe (manifest.json + sw.js) is checked in alongside for the part that cannot be automated — loading an unpacked extension needs an OS file dialog. It covers chrome.runtime messaging and the true kill-and-restart lifecycle.
Status
yarn test now runs both suites (it previously ran wasm-crypto's only): 10 Rust conformance tests and 12 consumer assertions.
Remaining for this issue: only the manual MV3 load, which needs a human. #2's golden vectors are still open and are the right next thing here — the current fixtures are CLI-derived rather than the crate's own published vectors.
Packaging done. `@quantus/crypto` builds, installs and has been consumed as a package. Every checkbox from the previous comment is closed except #2's vectors.
## Two more toolchain faults, same shape as the first
**`polkadot-dev-build-ts` will not build a non-`@polkadot/*` package.** It returns early in `buildJs` *and* when collecting `locals` for import rewriting. Renaming into someone else's scope to satisfy a string check would be worse than not using it, and nothing is lost — this package needs no deno variant, no rollup bundle, no cross-package import rewriting. `scripts/build-quantus-js.sh` is a plain `tsc` build, which also keeps `yarn build:js` byte-identical to upstream's behaviour.
**binaryen 105 silently corrupts the wasm.** This one is worth remembering. Upstream pins `version_105` (2021), which predates the externref tables wasm-bindgen 0.2.128 emits. `wasm-opt -Oz` "optimises" the table into something that fails at instantiation:
```
WebAssembly.Table.grow(): failed to grow table by 4
```
The wasm is **valid before wasm-opt and broken after**. Every `cargo test` still passes. Nothing in the build says a word. It only surfaces when a consumer calls `initWasm()`. `install-build-deps.sh` now fetches binaryen 123 into `binaryen-quantus/`, exactly as it does the second wasm-bindgen — three tools now, all for the same underlying reason.
It was the *consumer* test that caught it, which is why that test stages a real `node_modules` layout instead of importing from `build/` in place. In this repo `node_modules/@polkadot/wasm-util` symlinks to the package **source**, which carries no `exports` map, so a deep import resolves for a real consumer and fails here for reasons unrelated to our package. Staging tests module resolution too, which is half of what can break in a published package.
Also: the `@polkadot/wasm-util` import is now deep (`/base64`, `/fflate`) rather than via the package index — the index re-exports `packageDetect`, whose only job is a side effect registering with `@polkadot/util`, a peer dependency we would otherwise inherit for nothing.
ESM only. The consumers are ESM and the wasm-bindgen glue is ESM-only, so a CJS variant means a second generated glue or hand-written marshalling. Revisit if quantus/common's CJS build needs it — that is the one place this could bite.
## Real size, post-`wasm-opt`
| | wasm raw | zlib | base64 in JS |
|---|---:|---:|---:|
| upstream `@polkadot/wasm-crypto-wasm` 7.5.4 | 335,277 | 168,782 | 225,044 |
| `@quantus/crypto` | **234,292** | **109,649** | **146,200** |
Smaller than upstream's entire crypto blob on every axis.
## Cold start: 9 ms
`test/probe/` holds a module Worker served under the exact `extension_pages` CSP from both manifests. A module Worker has no `window` and no `document` — the property that matters, since an MV3 service worker has neither either. On Firefox:
```
hasDOM: false hasWindow: false
initWasm: ok (9.0 ms cold)
keygen: 1.0 ms
account: matches quantus-cli
sign: 3.0 ms (4627 bytes)
verify: 1.0 ms ok
ctx sep: ok (rejected under spec-147 ctx)
```
9 ms to base64-decode 146 KB, inflate to 234 KB and instantiate — paid on every service-worker wake, and not a problem. Those are ML-DSA-87 timings, the larger set; 65 is cheaper. The CSP is genuinely enforced: an earlier version of the page used an inline script and Firefox blocked it.
A real MV3 extension probe (`manifest.json` + `sw.js`) is checked in alongside for the part that cannot be automated — loading an unpacked extension needs an OS file dialog. It covers `chrome.runtime` messaging and the true kill-and-restart lifecycle.
## Status
`yarn test` now runs both suites (it previously ran `wasm-crypto`'s only): 10 Rust conformance tests and 12 consumer assertions.
Remaining for this issue: only the manual MV3 load, which needs a human. #2's golden vectors are still open and are the right next thing here — the current fixtures are CLI-derived rather than the crate's own published vectors.
Branch `quantus-crypto`, 5 commits. Moving to quantus/common#1.
Deviation, as agreed above: this shipped as a separate package, @quantus/crypto (0.3.0), not inside @polkadot/wasm-crypto. The dilithium crates need a modern Rust toolchain, and upstream's build is pinned to nightly-2022-06-24. packages/wasm-crypto* is untouched relative to upstream, and there is no asm.js fallback.
Built on the chain's own crates:qp-poseidon-core 3.1.0, and qp-rusty-crystals-dilithium and -hdwallet 4.1.1 (65 and 87).
Poseidon hash of a known public key equals the chain's account id:dev_account_ids_match_the_cli checks crystal_alice, dilithium_bob and crystal_charlie.
HD path matches:m/44'/189189'/0'/0'/{0,1}' is pinned against quantus-cli output. The crate's own HD vectors are not yet used; that is quantus/wasm#2.
Signing context separates: a signature under QUANTUS_EXTRINSIC verifies, and fails under the empty context (Rust tests, and the spec 148/147 checks in consumer.mjs).
Runs where it must: in the extension's Firefox background page and its Chrome MV3 service worker, where wallets were created and used, beyond the module-worker probe.
Size: recorded in the comment above.
It has since grown wormhole addresses (0.2.0) and nullifiers (0.3.0), pinned to the chain node's TEST_WORMHOLE_ADDRESS and to qp-wormhole-circuit's Nullifier::from_preimage respectively.
Stale doc: the "Staging vendor/" section of test/probe/README.md still mentions @polkadot/wasm-util, which was dropped in a6d3685c.
Closing. Checked against `main` (`dc295dc3`).
**Deviation, as agreed above:** this shipped as a separate package, `@quantus/crypto` (0.3.0), not inside `@polkadot/wasm-crypto`. The dilithium crates need a modern Rust toolchain, and upstream's build is pinned to nightly-2022-06-24. `packages/wasm-crypto*` is untouched relative to upstream, and there is no asm.js fallback.
- **Built on the chain's own crates:** `qp-poseidon-core` 3.1.0, and `qp-rusty-crystals-dilithium` and `-hdwallet` 4.1.1 (65 and 87).
- **Poseidon hash of a known public key equals the chain's account id:** `dev_account_ids_match_the_cli` checks crystal_alice, dilithium_bob and crystal_charlie.
- **HD path matches:** `m/44'/189189'/0'/0'/{0,1}'` is pinned against `quantus-cli` output. The crate's own HD vectors are not yet used; that is quantus/wasm#2.
- **Signing context separates:** a signature under `QUANTUS_EXTRINSIC` verifies, and fails under the empty context (Rust tests, and the spec 148/147 checks in `consumer.mjs`).
- **Runs where it must:** in the extension's Firefox background page and its Chrome MV3 service worker, where wallets were created and used, beyond the module-worker probe.
- **Size:** recorded in the comment above.
It has since grown wormhole addresses (0.2.0) and nullifiers (0.3.0), pinned to the chain node's `TEST_WORMHOLE_ADDRESS` and to `qp-wormhole-circuit`'s `Nullifier::from_preimage` respectively.
**Stale doc:** the "Staging vendor/" section of `test/probe/README.md` still mentions `@polkadot/wasm-util`, which was dropped in `a6d3685c`.
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.
Blocks everything else. See quantus/extension#1 for the full context, sizes and sources of truth.
Why here, and not a fresh package
The hard part of shipping this crypto to a browser extension is not the maths — it is the packaging.
@polkadot/wasm-cryptoalready solves exactly that problem: the wasm is base64-inlined into the JS rather than fetched, init is synchronous, there is an asm.js fallback, and the whole thing runs inside an MV3 service worker underscript-src 'self' 'wasm-unsafe-eval'(which both extension manifests already set). Rebuilding that pipeline in a new repo means re-solving a solved problem and then keeping two of them working.The counter-argument is real and worth recording: this couples us to upstream's Rust build, which we do not control. If that turns out to hurt more than the packaging saved, splitting
@quantus/cryptoout is a reasonable reversal — but start here.Wrap the chain's crates, do not reimplement
Vendor as dependencies, not as ports:
qp-rusty-crystals-dilithium(featuresml-dsa-65,ml-dsa-87)qp-poseidon-coreqp-rusty-crystals-hdwallet(featuresml-dsa-65,ml-dsa-87)These are the crates the runtime itself uses, published on crates.io, and
quantus-apps:quantus_sdk/rust/Cargo.tomlalready pins a working set of versions for a non-Rust consumer — copy that pinning. A hand-port of Poseidon2 (~2450 lines of Goldilocks field arithmetic and round constants) would be a second implementation to keep in step with the chain forever, which is the trap blackbeard.observer'sCLAUDE.mddescribes for SS58.Surface to export
Keep names and shapes in the style of the existing
@polkadot/wasm-cryptoexports so thecommonside reads like the sr25519/ed25519 arms next to it.mldsaKeypairFromSeed(seed: [u8;32], scheme) -> { publicKey, secretKey }— this is FIPS-204KeyGen_internal(ξ)verbatim;keypair_varin the dilithium crate doesSHAKE256(ξ ‖ k ‖ ℓ)with no Quantus-specific twistmldsaSign(secretKey, publicKey, message, ctx) -> sig— deterministic (the chain passeshedge: None);ctxis the FIPS-204 context, prefixed as[0, |ctx|, ctx…]mldsaVerify(publicKey, message, sig, ctx) -> boolposeidonHashBytes(bytes) -> [u8;32]—qp_poseidon_core::hash_bytes, the account-id derivationmldsaDeriveFromMnemonic(mnemonic, password, path, scheme) -> { publicKey, secretKey }Put HD derivation in wasm too
Tempting to do the derivation in JS — it is only BIP39 plus HMAC-SHA512 — but it is the same trap. The master key is the literal string
"Dilithium seed", the path is hardened-only, and BIP39 seeding must be the 64-byte legacy seed, notmnemonicToMiniSecret(the Substrate variant, which polkadot-js reaches for by default and which would silently produce wrong keys that look perfectly valid). One implementation, in the crate that the chain and the mobile wallet already agree with.Acceptance
poseidonHashBytesof a known public key equals theAccountId32the chain derives for itm/44'/189189'/0'/0'/1'matches the golden vectors (#2)"QUANTUS_EXTRINSIC"verifies with the Rustverify_ml_dsa_65, and does not verify under the empty contextThat last two are risks, not formalities. ML-DSA is not small, and
@polkadot/wasm-cryptoinlines its wasm as base64 into a JS file that a service worker has to parse on every cold start. If the delta turns out to be unacceptable, say so on this issue before working around it — lazy-loading or splitting the Quantus wasm into its own artifact is a design change, not an implementation detail.Investigated before building. The plan survives, but two of its assumptions do not — and the risk it flagged turns out not to be one.
1. The crates cannot share upstream's Cargo build
scripts/rust-version.shpinsRUST_VER=nightly-2022-06-24, built viaxargo build -Z build-std.qp-rusty-crystals-dilithiumuses inlineconst { … }blocks — stable since Rust 1.79 (June 2024) — in five files:The crate does not declare
#![feature(inline_const)], so it will not parse on a 2022 nightly. The chain itself pins1.93.0. These are four years apart and not reconcilable.Bumping upstream's toolchain is not the answer either:
wasm-crypto's dependency set is 2019-era (schnorrkel0.9.1,ed25519-dalek1.0.0-pre.4,curve25519-dalek2.1.0,rand0.7,sha20.8,wasm-bindgen=0.2.79). Modernising it would mean rewriting the existing sr25519/ed25519 crypto build, which is exactly what the rebasability convention in quantus/extension#1 forbids.2.
Bridgecannot drive a modern wasm-bindgen either@polkadot/wasm-bridge'sBridge<C>is pleasingly generic, and reusing it was part of the appeal. But it implements wasm-bindgen 0.2.79's ABI — a JS-side#heaparray with manual slot management. wasm-bindgen 0.2.128 uses externref tables (__wbindgen_init_externref_table,__wbindgen_externref_table_grow), whichWbgdoes not provide.We do not need it. Our whole surface is byte-slices in, bytes or bool out — no
JsValue, no js-sys. wasm-bindgen's own generated glue plusinitSync({ module: bytes })covers it in far less code than adaptingBridge, andinitSynctakes aBufferSourcedirectly, so there is nofetchand nothing that upsets the extension CSP.3. Size: not a problem, and the numbers are better than expected
The issue asked for this to be recorded, and flagged it as a possible design-changing risk. It is not:
@polkadot/wasm-crypto-wasm7.5.4The entire Quantus crypto surface is the same size as upstream's whole crypto blob, and smaller compressed — and ours is measured before
wasm-opt -Oz, where upstream's is after. Roughly doubling the extension's inlined wasm payload, ~225 KB → ~396 KB of base64. No lazy-loading, no splitting, no redesign needed.4. It builds
Clean on stable rustc 1.98 for
wasm32-unknown-unknownwith wasm-bindgen 0.2.128, nogetrandomjs feature required — the qp crates take entropy as a parameter and sign deterministically, so nothing reaches for a system RNG.Revised plan
A second package with its own Cargo crate, in this repo, rather than new files inside
wasm-crypto:packages/quantus-crypto— its ownCargo.toml, edition 2021, modern wasm-bindgen, built with the chain's toolchain rather than upstream's nightlywasm-cryptoand its Cargo graph stay byte-identical to upstream, so rebasing stays boringscripts/pack-wasm-base.mjs(zlib + base64 embedding) and@polkadot/wasm-util(base64 decode, fflate inflate)Bridge; use wasm-bindgen's generated glue withinitSyncwasm2jsover ML-DSA would be enormous and slow, and both extension manifests already setwasm-unsafe-eval, so wasm is always available where we need it. If a non-wasm target ever matters, that is a separate decision with a real cost attached.This keeps the reason we chose this repo — the packaging pipeline and the CSP-safe init — while dropping the part that turned out to be unshareable. The counter-argument recorded in the issue (coupling to a build we do not control) is now half-answered: we are coupled to their packaging, not their compiler.
Naming follows the chain's own variant bytes: scheme
0= ML-DSA-87,1= ML-DSA-65, so the selector threaded through this API is the same number that ends up on the wire, andTYPE_PREFIXdownstream in quantus/common#2 is the identity function on it.Branch
quantus-cryptopushed — three commits. The crate is done and verified; the packaging is written and its runtime behaviour proven, but the ESM/CJS build has not been exercised.What is done
packages/quantus-crypto— its own crate,wasm-cryptountouched. Exportsext_mldsa_sizes,ext_mldsa_is_scheme,ext_mldsa_from_seed,ext_mldsa_sign,ext_mldsa_verify,ext_poseidon_hash,ext_mldsa_derive, following upstream'sext_<family>_<op>convention.Sizes are exported, not hardcoded.
ext_mldsa_sizes(scheme)returns[public, secret, signature, signature_with_public]. These are consensus-critical — the runtime decodes a fixed-size array with no compact length prefix — and a JS constant that drifted from the crate would re-frame every byte after the signature while looking healthy. quantus/common#2 should call this rather than writing 1952/5261/7219 down anywhere.Logic split from the
#[wasm_bindgen]wrappers. Discovered the hard way:JsErrorcannot be constructed on a non-wasm target — it panics with "cannot call wasm-bindgen imported functions on non-wasm targets" — so every function returning one was untestable bycargo test, and the error paths are what most needs testing. Each module now has a plain-> Result<_, String>body with a wrapper that only translates.Verified against an independent oracle
Not against our own output. The
quantusCLI 2.2.2 is installed locally, and itsdeveloper create-test-walletsandwallet importwere the reference:crystal_alice/dilithium_bob/crystal_charlieaccount idsm/44'/189189'/0'/0'/1'm/44'/189189'/0'/0'/0'QUANTUS_EXTRINSIC10
cargo testcases pin all of it, plus unhardened-path rejection, mismatched key halves, over-long context, and that a BIP39 passphrase changes the result while""means "none".The HD fixture is the public Substrate dev phrase, deliberately — the mnemonics the CLI generated for me are real keys and do not belong in a public repo, whereas that phrase is in polkadot-sdk, polkadot-js and every tutorial, so pinning it commits no secret.
The JS path is proven, the JS build is not
base64 → inflate → initSync({ module })was run against the real build output: nofetchanywhere,crystal_alice's account id correct through the JS boundary,sig ‖ pkthe right length, andJsErrorsurfacing as a normal JS exception.What has not run is
polkadot-dev-build-ts— the ESM/CJS dual emit, theexportsmap, rollup. That needsyarn installin this repo, which I have not done. One snag already surfaced and is fixed:bytes.jsis CJS while the package is"type": "module", so the CJS copy needs its own{"type":"commonjs"}directory marker, as upstream does forwasm-crypto-wasm.Also not run:
wasm-opt -Oz, since binaryen is not fetched. Every size figure here is therefore an upper bound.Build
scripts/build-quantus.sh, separate frombuild-wasm.shon purpose, andinstall-build-deps.shnow fetches a second wasm-bindgen (0.2.128) intobindgen-quantus/— the two ABIs cannot share a binary. No asm.js step, per the reasoning above.Remaining before this closes
yarn installandpolkadot-dev-build-ts; fix whatever the dual-module build objects tobuild-quantus.shincludingwasm-opt, and record the real (post-opt) sizecargo testfor this crate into the repo'stestscript, which currently only runswasm-crypto'sPackaging done.
@quantus/cryptobuilds, installs and has been consumed as a package. Every checkbox from the previous comment is closed except #2's vectors.Two more toolchain faults, same shape as the first
polkadot-dev-build-tswill not build a non-@polkadot/*package. It returns early inbuildJsand when collectinglocalsfor import rewriting. Renaming into someone else's scope to satisfy a string check would be worse than not using it, and nothing is lost — this package needs no deno variant, no rollup bundle, no cross-package import rewriting.scripts/build-quantus-js.shis a plaintscbuild, which also keepsyarn build:jsbyte-identical to upstream's behaviour.binaryen 105 silently corrupts the wasm. This one is worth remembering. Upstream pins
version_105(2021), which predates the externref tables wasm-bindgen 0.2.128 emits.wasm-opt -Oz"optimises" the table into something that fails at instantiation:The wasm is valid before wasm-opt and broken after. Every
cargo teststill passes. Nothing in the build says a word. It only surfaces when a consumer callsinitWasm().install-build-deps.shnow fetches binaryen 123 intobinaryen-quantus/, exactly as it does the second wasm-bindgen — three tools now, all for the same underlying reason.It was the consumer test that caught it, which is why that test stages a real
node_moduleslayout instead of importing frombuild/in place. In this reponode_modules/@polkadot/wasm-utilsymlinks to the package source, which carries noexportsmap, so a deep import resolves for a real consumer and fails here for reasons unrelated to our package. Staging tests module resolution too, which is half of what can break in a published package.Also: the
@polkadot/wasm-utilimport is now deep (/base64,/fflate) rather than via the package index — the index re-exportspackageDetect, whose only job is a side effect registering with@polkadot/util, a peer dependency we would otherwise inherit for nothing.ESM only. The consumers are ESM and the wasm-bindgen glue is ESM-only, so a CJS variant means a second generated glue or hand-written marshalling. Revisit if quantus/common's CJS build needs it — that is the one place this could bite.
Real size, post-
wasm-opt@polkadot/wasm-crypto-wasm7.5.4@quantus/cryptoSmaller than upstream's entire crypto blob on every axis.
Cold start: 9 ms
test/probe/holds a module Worker served under the exactextension_pagesCSP from both manifests. A module Worker has nowindowand nodocument— the property that matters, since an MV3 service worker has neither either. On Firefox:9 ms to base64-decode 146 KB, inflate to 234 KB and instantiate — paid on every service-worker wake, and not a problem. Those are ML-DSA-87 timings, the larger set; 65 is cheaper. The CSP is genuinely enforced: an earlier version of the page used an inline script and Firefox blocked it.
A real MV3 extension probe (
manifest.json+sw.js) is checked in alongside for the part that cannot be automated — loading an unpacked extension needs an OS file dialog. It coverschrome.runtimemessaging and the true kill-and-restart lifecycle.Status
yarn testnow runs both suites (it previously ranwasm-crypto's only): 10 Rust conformance tests and 12 consumer assertions.Remaining for this issue: only the manual MV3 load, which needs a human. #2's golden vectors are still open and are the right next thing here — the current fixtures are CLI-derived rather than the crate's own published vectors.
Branch
quantus-crypto, 5 commits. Moving to quantus/common#1.Closing. Checked against
main(dc295dc3).Deviation, as agreed above: this shipped as a separate package,
@quantus/crypto(0.3.0), not inside@polkadot/wasm-crypto. The dilithium crates need a modern Rust toolchain, and upstream's build is pinned to nightly-2022-06-24.packages/wasm-crypto*is untouched relative to upstream, and there is no asm.js fallback.qp-poseidon-core3.1.0, andqp-rusty-crystals-dilithiumand-hdwallet4.1.1 (65 and 87).dev_account_ids_match_the_clichecks crystal_alice, dilithium_bob and crystal_charlie.m/44'/189189'/0'/0'/{0,1}'is pinned againstquantus-clioutput. The crate's own HD vectors are not yet used; that is quantus/wasm#2.QUANTUS_EXTRINSICverifies, and fails under the empty context (Rust tests, and the spec 148/147 checks inconsumer.mjs).It has since grown wormhole addresses (0.2.0) and nullifiers (0.3.0), pinned to the chain node's
TEST_WORMHOLE_ADDRESSand toqp-wormhole-circuit'sNullifier::from_preimagerespectively.Stale doc: the "Staging vendor/" section of
test/probe/README.mdstill mentions@polkadot/wasm-util, which was dropped ina6d3685c.