Add ML-DSA-65/87, Poseidon2 and Quantus HD derivation to @polkadot/wasm-crypto #1

Closed
opened 2026-09-10 10:11:27 +00:00 by grenade · 4 comments
Owner

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.

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.
Author
Owner

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
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.

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.
Author
Owner

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
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
Author
Owner

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.

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.
Author
Owner

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.

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`.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: quantus/wasm#1