Files
wasm/packages/quantus-crypto
rob thijssenandClaude Opus 5 b882f914e7 build(quantus-crypto): buildable, installable and tested as a package
The JS build now runs end to end and the built package has been consumed the way
quantus/common will consume it. Four things had to be worked out.

polkadot-dev-build-ts will not build this package. It returns early for any name
not starting with @polkadot/, in both 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 the tool, and nothing is lost: this package needs
no deno variant, no rollup bundle, no cross-package import rewriting. A plain tsc
build lives in scripts/build-quantus-js.sh, which also keeps `yarn build:js`
byte-identical to upstream's behaviour.

binaryen 105 silently breaks the wasm. Upstream pins version_105 (2021), which
predates the externref tables wasm-bindgen 0.2.128 emits; wasm-opt "optimises"
the table into something that fails at instantiation with `WebAssembly.Table.
grow(): failed to grow table by 4`. The wasm is valid before wasm-opt and broken
after, every cargo test still passes, and it only surfaces when a consumer tries
to init. install-build-deps.sh now fetches binaryen 123 alongside, exactly as it
does a second wasm-bindgen.

The wasm-util dependency is imported deeply. Its package index re-exports
packageDetect, whose only job is a side effect registering with @polkadot/util —
a peer dependency we would inherit for nothing. base64 and fflate are pure
functions with no dependencies, so the deep paths are both lighter and honest.

ESM only, and the CJS scaffolding is removed. The consumers are ESM and the
wasm-bindgen glue is ESM-only, so a CJS variant would mean a second generated
glue or hand-written marshalling. Revisit if quantus/common's CJS build needs it.

Also: the pack step must run after tsc, which clears build/; the checked-in
bindings are refreshed by the build so they cannot drift; and both test suites
are wired into the repo's test script, which previously ran wasm-crypto's only.

The consumer test stages a real node_modules layout rather than testing in place,
because 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 that have nothing to do with our package.
Staging tests module resolution too, which is half of what can break in a
published package. It is also what caught the binaryen fault.

Post-wasm-opt: 234,292 raw / 109,649 zlib / 146,200 base64 — smaller than
upstream's entire wasm-crypto blob (335,277 / 168,782 / 225,044).

Refs quantus/wasm#1

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-10 14:17:15 +03:00
..

@quantus/crypto

Quantus post-quantum crypto for the browser: ML-DSA-65 and ML-DSA-87 signatures, Poseidon2-over-Goldilocks account-id hashing, and hardened BIP44 key derivation.

Every function delegates to the crates the Quantus runtime itself uses — qp-rusty-crystals-dilithium, qp-poseidon-core, qp-rusty-crystals-hdwallet — rather than reimplementing them. A browser wallet that disagreed with the chain about a key or a signature would produce perfectly well-formed output that the chain rejects, and nothing on this side could tell.

Why a separate package from @polkadot/wasm-crypto

They cannot share a Cargo build. wasm-crypto is compiled with nightly-2022-06-24 against a 2019-era dependency set; the ML-DSA crates use inline const {} blocks that require Rust >= 1.79. Modernising the older build would mean rewriting upstream's sr25519/ed25519 crypto, which is the thing most worth leaving untouched so rebases onto upstream stay boring.

What is shared is the packaging: the WASM is zlib-compressed and base64'd into the JS at build time, so nothing is fetched at runtime. That matters because the consumer is an MV3 service worker under script-src 'self' 'wasm-unsafe-eval', which can compile WASM but cannot usefully fetch it, and because callers like pair.sign() are synchronous and have no await to give.

@polkadot/wasm-bridge is deliberately not used: its Bridge implements wasm-bindgen 0.2.79's JS-heap ABI, while this crate builds with 0.2.128, which uses externref tables. wasm-bindgen's own generated glue plus initSync is both smaller and correct.

Scheme selector

Scheme.MlDsa87 = 0, Scheme.MlDsa65 = 1 — these are the chain's own DilithiumSignatureScheme variant indices, so the number threaded through this API is the byte that ends up on the wire. New accounts use ML-DSA-65; ML-DSA-87 is legacy and must be supported but never chosen.

Signing context

ML-DSA hashes a context into the signature. Quantus extrinsics on spec >= 148 are verified under QUANTUS_EXTRINSIC, earlier specs under the empty context. A signature made under the wrong one is cryptographically valid, rejected by the chain, and indistinguishable locally — so use contextForSpec(specVersion) rather than picking one by hand. Nothing here guesses on your behalf.

Sizes come from the crate

sizes(scheme) returns the public/secret/signature lengths rather than exposing constants to copy. They are consensus-critical — the runtime decodes a fixed-size array with no compact length prefix — and a JS constant that drifted would re-frame every byte after the signature while looking entirely healthy.

Building

yarn install-build-deps    # downloads wasm-bindgen 0.2.128 and binaryen
./scripts/build-quantus.sh

The Rust toolchain is pinned in rust-toolchain.toml to the same channel the chain builds its runtime with.

Tests

cargo test runs conformance tests whose expected values come from the quantus CLI, not from this crate — the dev-genesis account ids, HD derivation at both schemes' default paths, and context separation. A test that pinned our own output would keep passing through exactly the drift they exist to catch.