Various WASM wrappers around Rust crates
It is split up into a number of internal packages, namely utilities -
These are split from the polkadot-js/util repo where it is heavily used as part of @polkadot/util-crypto. (There JS fallbacks are available for some interfaces, e.g. hashing, but for sr25519 WASM is the only interface). Since these don't undergo massive changes on a daily basis and has a build overhead (WASM compilation & optimisation), it is better managed as a seperate repo with a specific CI configuration.
Contributions are welcome!
To start off, this repo (along with others in the @polkadot family) uses yarn workspaces to organise the code. As such, after cloning, its dependencies should be installed via yarn, not via npm; the latter will result in broken dependencies.
26 activities
Checked against main (dc295dc3). Staying open.
Done:
quantus-cli, and the node's TEST_WORMHOLE_ADDRESS (src/rs/tests.rs).signing_is_deterministic (ML-DSA-65 only).QUANTUS_EXTRINSIC fails under the empty context.qp-wormhole-circuit in a known-answer test.Remaining:
test_vectors_65.rs and _87.rs, extracted rather than retyped. The only HD pins today are two CLI-derived hashes.tests.rs and test/consumer.mjs.QUANTUS_EXTRINSIC.yarn test runs Rust only, and the .github workflows are upstream's.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-core 3.1.0, and qp-rusty-crystals-dilithium and -hdwallet 4.1.1 (65 and 87).dev_account_ids_match_the_cli checks crystal_alice, dilithium_bob and crystal_charlie.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.QUANTUS_EXTRINSIC verifies, and fails under the empty context (Rust tests, and the spec 148/147 checks in consumer.mjs).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.
dc295dc Merge branch 'quantus-crypto' into quantus-codec4e5b479 feat(quantus-crypto): wormhole nullifiersd61e886 Merge branch 'quantus-crypto' into quantus-codec0b1cfe3 feat(quantus-crypto): derive wormhole addresses09a96b6 feat: render account ids as SS58 when a prefix is setdc295dc Merge branch 'quantus-crypto' into quantus-codec4e5b479 feat(quantus-crypto): wormhole nullifiers4e5b479 feat(quantus-crypto): wormhole nullifiersd61e886 Merge branch 'quantus-crypto' into quantus-codec0b1cfe3 feat(quantus-crypto): derive wormhole addresses0b1cfe3 feat(quantus-crypto): derive wormhole addresses09a96b6 feat: render account ids as SS58 when a prefix is setShipped in @quantus/codec 0.4.0.
storageTarget(pallet, item, keys) returns { key, valueTy, default } — the key being twox128(prefix) ‖ twox128(item) plus each map key hashed by the hasher the entry declares, with the number of keys checked against the number of hashers. decodeStorage(valueTy, bytes) reads the value. Nothing in it knows that System::Account is a Blake2_128Concat map over an AccountId32; the prefix, hashers, key types, value type and default all come out of the metadata.
The Default/Optional distinction is carried as asked: default is hex for a Default entry and null for an Optional one, so an account nobody has funded reads as a zero balance rather than as a failure.
Proven against Heisenberg through quantus/extension's tier-1 harness, which now reads real balances and asserts they moved — including catching that two batch_all calls it had reported as reverted had in fact funded their targets with exactly 50 HEI each. Unit tests pin the key layout (16+16+16+32 for System::Account) and the declared default decoding to a zero balance, against real spec-148 metadata.
Closing short of one stated acceptance criterion, deliberately. The extension's account list does not yet show a balance — that is extension-side UI work with its own issue to be written, not a gap in this package. The tier-1 harness also still takes its nonce from system_accountNextIndex rather than System::Account, and that turns out to be the correct call for choosing a nonce: it counts pending pool transactions, which is exactly what a signer wants. It is the wrong call for waiting on inclusion, and the harness no longer uses it for that — see quantus/extension#7.
One other thing came out of the same work and is worth noting here, since it is the same package: a multi-field variant with named fields only accepted a positional array, so Utility.batch_all — whose Vec<RuntimeCall> holds calls spelled exactly like top-level ones — could not be encoded at all. Nested calls now take the object form at any depth.
e6ff57a feat: storage addressing, nested calls, and a stated integer conventionf1c5166 feat: accept pre-encoded extension values, validated by round tripe8bf9e2 fix(quantus-crypto): strip wasm-bindgen's fetch-based init from the shipped gluea6d3685 refactor(quantus-crypto): drop the @polkadot/wasm-util dependencyPackaging done. @quantus/crypto builds, installs and has been consumed as a package. Every checkbox from the previous comment is closed except #2's vectors.
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.
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.
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.
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.
8323e44 test(quantus-crypto): browser probes for the constraints node cannot testb882f91 build(quantus-crypto): buildable, installable and tested as a packageBranch 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.
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.
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.
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.
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.
yarn install and polkadot-dev-build-ts; fix whatever the dual-module build objects tobuild-quantus.sh including wasm-opt, and record the real (post-opt) sizecargo test for this crate into the repo's test script, which currently only runs wasm-crypto's02a3f00 chore: ignore the second bindgen download1f1f572 feat(quantus-crypto): JS surface, build scripts and CSP-safe sync init6eb04f6 feat(quantus-crypto): ML-DSA, Poseidon2 and HD derivation as a separate crateInvestigated before building. The plan survives, but two of its assumptions do not — and the risk it flagged turns out not to be one.
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.
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.
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.
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.
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 nightlywasm-crypto and 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 with initSyncwasm2js 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.