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