Files
rob thijssen 4e5b479db4 feat(quantus-crypto): wormhole nullifiers
A deposit to a wormhole address is spent when its nullifier,
poseidon2(poseidon2(salt || secret || transfer_count)), is in
Wormhole::UsedNullifiers. Working out a wormhole balance means computing one
for each deposit. That needs the address's secret, which never leaves WASM.

wormholeNullifiers(mnemonic, password, account, branch, start, addresses,
first, count) returns them for a run of addresses and a run of transfer
counts. The BIP39 seed is stretched once per call; 40 addresses x 256 counts
takes 194 ms in node. A call is capped at 100,000 nullifiers.

This is ported onto qp-poseidon-core, not qp-wormhole-circuit, which would
bring plonky2 into the WASM. The circuit crate is a dev-dependency only, as
the reference: a known-answer test compares the port with
Nullifier::from_preimage across secrets at and above the Goldilocks-prime
limb edge and transfer counts across both 32-bit limbs, and asserts that
enough cases were actually compared rather than skipped.

The doc comments say to check nullifiers against a local copy of the spent
set, never by key: exits publish nullifiers, so a lookup names the exit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-16 18:13:57 +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.

The only runtime dependency is fflate, for zlib inflate. Base64 decoding is fifteen lines here rather than a dependency. Both were originally taken from @polkadot/wasm-util, which turned out to cost more than it saved: its index re-exports packageDetect, dragging in a @polkadot/util peer dependency for a side effect we do not want, and being a workspace package it resolved through its own repo's node_modules when this package was consumed by symlink from another checkout — which is exactly how quantus/common consumes it during development.

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.