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
This commit is contained in:
rob thijssen
2026-09-10 14:17:15 +03:00
parent 02a3f004d7
commit b882f914e7
14 changed files with 225 additions and 38 deletions

2
.gitignore vendored
View File

@@ -1,8 +1,10 @@
binaryen/
binaryen-quantus/
bindgen/
bindgen-quantus/
build/
build-*/
build-test/
bytes/
coverage/
node_modules/

View File

@@ -25,6 +25,8 @@
"scripts": {
"build": "yarn build:wasm",
"build:js": "./scripts/build-js.sh",
"build:quantus": "./scripts/build-quantus.sh",
"build:quantus:js": "./scripts/build-quantus-js.sh",
"build:release": "polkadot-ci-ghact-build",
"build:rollup": "polkadot-exec-rollup --config",
"build:wasm": "./scripts/build.sh",
@@ -34,8 +36,10 @@
"deno:check": "deno check --import-map=import_map.json mod.ts",
"lint": "polkadot-dev-run-lint",
"postinstall": "polkadot-dev-yarn-only",
"test": "yarn test:wasm-crypto:rust",
"test": "yarn test:wasm-crypto:rust && yarn test:quantus-crypto:rust",
"test:js": "yarn test:wasm-crypto:js",
"test:quantus-crypto:js": "./scripts/test-quantus-js.sh",
"test:quantus-crypto:rust": "cd packages/quantus-crypto && RUST_BACKTRACE=full cargo test --release",
"test:wasm-crypto:deno": "deno test --allow-read --import-map=import_map.json packages/wasm-crypto/test/deno.ts",
"test:wasm-crypto:js": "yarn test:wasm-crypto:js:jest && yarn test:wasm-crypto:js:node",
"test:wasm-crypto:js:jest": "polkadot-dev-run-test --env node --loader ./packages/wasm-crypto/test/loader-build.js",

View File

@@ -1,6 +0,0 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
export declare const lenIn: number;
export declare const lenOut: number;
export declare const bytes: string;

View File

@@ -1,10 +0,0 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
// Generated as part of the build, do not edit
exports.lenIn = 0;
exports.lenOut = 0;
exports.bytes = '';

View File

@@ -1,3 +0,0 @@
{
"type": "commonjs"
}

View File

@@ -1,7 +1,12 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { base64Decode, unzlibSync } from '@polkadot/wasm-util';
// Deep imports, not the package index. `@polkadot/wasm-util`'s index re-exports
// `packageDetect`, whose only job is a side effect registering the package with
// `@polkadot/util` — a peer dependency we would otherwise inherit for nothing.
// These two subpaths are pure functions with no dependencies at all.
import { base64Decode } from '@polkadot/wasm-util/base64';
import { unzlibSync } from '@polkadot/wasm-util/fflate';
import { bytes, lenOut } from './bytes.js';
import { initSync } from './generated/quantus_crypto.js';

View File

@@ -0,0 +1,56 @@
// Copyright 2026 @quantus/crypto authors & contributors
// SPDX-License-Identifier: Apache-2.0
//
// Consumes the *built* package exactly as quantus/common will — a plain import of
// build output, nothing reaching into src or poking the wasm by hand. Run after
// ./scripts/build-quantus.sh.
//
// This exists because the unit tests in src/rs/tests.rs cannot catch packaging
// faults. A wasm that is valid before `wasm-opt` and broken after it passes every
// cargo test and fails here, which is exactly how binaryen 105's mishandling of
// externref tables was found.
import {
accountFromPublicKey, contextForSpec, EXTRINSIC_MIN_SPEC, initWasm,
isReady, keypairFromMnemonic, keypairFromSeed, Scheme, SCHEME_NAME,
sign, signatureWithPublicKey, sizes, verify
} from '@quantus/crypto';
let fail = 0;
const eq = (l, g, w) => { const ok = String(g) === String(w); if (!ok) fail++;
console.log(`${ok ? 'PASS' : 'FAIL'} ${l}`); if (!ok) console.log(` got ${g}\n want ${w}`); };
eq('initWasm() returns no error', initWasm(), 'null');
eq('isReady()', isReady(), true);
const s65 = sizes(Scheme.MlDsa65);
eq('ML-DSA-65 sizes', JSON.stringify(s65), '{"publicKey":1952,"secretKey":4032,"signature":3309,"signatureWithPublicKey":5261}');
eq('scheme name', SCHEME_NAME[Scheme.MlDsa65], 'ml-dsa-65');
eq('variant byte is the enum value', Scheme.MlDsa87, 0);
// crystal_alice, via the public API only
const pair87 = keypairFromSeed(new Uint8Array(32), Scheme.MlDsa87);
eq('crystal_alice account id',
Buffer.from(accountFromPublicKey(pair87.publicKey)).toString('hex'),
'1883df2ae47d1fd428a6b8237ad7b59cf0facccaacac4541ef7758be44b3c333');
// HD derivation, dev phrase, ML-DSA-65 default path
const DEV = 'bottom drive obey lake curtain smoke basket hold race lonely fit walk';
const hd = keypairFromMnemonic(DEV, '', "m/44'/189189'/0'/0'/1'", Scheme.MlDsa65);
eq('dev phrase account id (ML-DSA-65)',
Buffer.from(accountFromPublicKey(hd.publicKey)).toString('hex'),
'f647dbdefebcfcf726ba078a83481ffc6f4f33004fdfb4cedacf5a5391bc8f00');
// the signing-context boundary
const msg = new TextEncoder().encode('extrinsic payload');
const ctx = contextForSpec(EXTRINSIC_MIN_SPEC);
const sig = sign(msg, hd, ctx, Scheme.MlDsa65);
eq('signature length', sig.length, s65.signature);
eq('verifies at spec 148', verify(msg, sig, hd.publicKey, ctx, Scheme.MlDsa65), true);
eq('does NOT verify at spec 147', verify(msg, sig, hd.publicKey, contextForSpec(147), Scheme.MlDsa65), false);
eq('contextForSpec(147) is empty', contextForSpec(147).length, 0);
// the wire form
eq('sig || pk length', signatureWithPublicKey(sig, hd.publicKey).length, s65.signatureWithPublicKey);
process.exit(fail ? 1 : 0);

View File

@@ -0,0 +1,26 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": "..",
"composite": false,
"declaration": true,
"outDir": "./build",
"rootDir": "./src",
"paths": {
"@polkadot/wasm-util/base64": [
"wasm-util/build/base64.d.ts"
],
"@polkadot/wasm-util/fflate": [
"wasm-util/build/fflate.d.ts"
]
},
"emitDeclarationOnly": false
},
"exclude": [
"**/*.spec.ts"
],
"include": [
"src/**/*.ts"
],
"references": []
}

58
scripts/build-quantus-js.sh Executable file
View File

@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Copyright 2026 @quantus/crypto authors & contributors
# SPDX-License-Identifier: Apache-2.0
# Builds the JS side of packages/quantus-crypto.
#
# Not `polkadot-dev-build-ts`, because that tool returns early for any package
# not named `@polkadot/*` — twice, in `buildJs` and when collecting `locals` for
# import rewriting. Renaming the package into someone else's scope to satisfy a
# string check would be worse than not using the tool.
#
# Nothing is lost by that: this package needs none of what the tool adds (a deno
# variant, a rollup bundle, cross-package import rewriting, generated exports
# maps). It is four TypeScript files and two generated artifacts. Keeping it out
# also means `yarn build:js` stays byte-identical to upstream's behaviour, which
# is the rebasability convention in quantus/extension#1.
#
# ESM only. The consumers — quantus/common and the extension — are ESM, and the
# wasm-bindgen glue is ESM-only, so a CJS variant would mean either a second
# generated glue or hand-written marshalling. See quantus/wasm#1 before adding one.
set -e
PKG=packages/quantus-crypto
CRATE=quantus_crypto
echo "*** Building @quantus/crypto"
rm -rf $PKG/build-tsc
yarn polkadot-exec-tsc --outDir $PKG/build-tsc --project $PKG/tsconfig.build.json
# tsc emits only what it compiles; the generated glue and the packed bytes are
# .js and have to be carried over by hand.
mkdir -p $PKG/build/generated
cp -r $PKG/build-tsc/* $PKG/build/
cp $PKG/src/generated/$CRATE.js $PKG/build/generated/$CRATE.js
cp $PKG/src/generated/$CRATE.d.ts $PKG/build/generated/$CRATE.d.ts
rm -rf $PKG/build-tsc
# package.json, with the paths rewritten for a consumer installing the build
# output rather than the source tree.
node -e "
const fs = require('node:fs');
const pkg = JSON.parse(fs.readFileSync('$PKG/package.json', 'utf-8'));
delete pkg.private;
pkg.main = './index.js';
pkg.types = './index.d.ts';
pkg.exports = {
'.': { types: './index.d.ts', default: './index.js' },
'./package.json': './package.json'
};
fs.writeFileSync('$PKG/build/package.json', JSON.stringify(pkg, null, 2) + '\n');
"
cp $PKG/README.md $PKG/build/README.md
echo "*** Built $(ls $PKG/build/*.js | wc -l) modules"

View File

@@ -32,8 +32,24 @@ echo "*** Converting to WASM"
--out-dir packages/$PKG/build-wasm \
--target web
# The glue is a build artifact but is checked in, so the package can be built
# without a Rust toolchain — the same reasoning as upstream checking in an empty
# bytes.js. Copy it back so the two never drift.
echo "*** Updating checked-in bindings"
cp packages/$PKG/build-wasm/$CRATE.js packages/$PKG/src/generated/$CRATE.js
cp packages/$PKG/build-wasm/$CRATE.d.ts packages/$PKG/src/generated/$CRATE.d.ts
# binaryen-quantus, not binaryen. Upstream pins version_105 (2021), which predates
# the externref tables wasm-bindgen 0.2.128 emits: it "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, and nothing in the build says so — it only surfaces
# when a consumer tries to init. Same shape of problem as the two bindgens.
echo "*** Optimising WASM output"
./binaryen/bin/wasm-opt $WASM -Oz -o $OPT
./binaryen-quantus/bin/wasm-opt $WASM -Oz -o $OPT
# Must come before packing: tsc clears build/, which is where bytes.js lands.
./scripts/build-quantus-js.sh
echo "*** Packing WASM into baseX"
PKG_NAME=$PKG CRATE_NAME=$CRATE node ./scripts/pack-quantus-base.mjs

View File

@@ -74,3 +74,15 @@ if [ ! -d "bindgen-quantus" ]; then
curl -L $BINDGEN_REPO/releases/download/$QUANTUS_BINDGEN_VER/$QUANTUS_BINDGEN_ZIP.tar.gz | tar xz
mv $QUANTUS_BINDGEN_ZIP bindgen-quantus
fi
# Quantus: a second binaryen, for the same reason as the second wasm-bindgen.
# version_105 above cannot handle externref tables and silently produces a wasm
# that fails to instantiate. See quantus/wasm#1.
QUANTUS_BINARYEN_VER=version_123
QUANTUS_BINARYEN_ZIP=binaryen-$QUANTUS_BINARYEN_VER-x86_64-linux
if [ ! -d "binaryen-quantus" ]; then
echo "*** Downloading binaryen for quantus-crypto"
curl -L $BINARYEN_REPO/releases/download/$QUANTUS_BINARYEN_VER/$QUANTUS_BINARYEN_ZIP.tar.gz | tar xz
mv binaryen-$QUANTUS_BINARYEN_VER binaryen-quantus
fi

View File

@@ -3,8 +3,10 @@
// A near-copy of pack-wasm-base.mjs. Kept separate rather than parameterised
// because that script is upstream's and rebases should not have to reconcile our
// changes with theirs — the duplication is the cheaper of the two costs. It also
// emits no deno variant, which we do not ship.
// changes with theirs — the duplication is the cheaper of the two costs.
//
// ESM only: no deno variant and no CJS variant, matching what the package ships.
// This must run *after* the TypeScript build, which clears build/.
import fs from 'node:fs';
@@ -12,7 +14,6 @@ import { zlibSync } from 'fflate/node';
const PKG_NAME = process.env['PKG_NAME'];
const CRATE_NAME = process.env['CRATE_NAME'];
const DIR_CJS = `./packages/${PKG_NAME}/build/cjs`;
const DIR_ESM = `./packages/${PKG_NAME}/build`;
const HDR = `// Copyright 2026 @quantus/crypto authors & contributors\n// SPDX-License-Identifier: Apache-2.0\n\n// Generated as part of the build, do not edit\n`;
@@ -22,19 +23,7 @@ const base64 = compressed.toString('base64');
console.log(`*** Compressed WASM: in=${data.length}, out=${compressed.length}, opt=${(100 * compressed.length / data.length).toFixed(2)}%, base64=${base64.length}`);
fs.mkdirSync(DIR_CJS, { recursive: true });
// Both module systems, as upstream does for wasm-crypto-wasm. The CJS copy sits
// under a directory carrying its own `{"type":"commonjs"}`, because the package
// itself is `"type": "module"` and node would otherwise refuse to load an
// `exports.`-style file from it.
fs.writeFileSync(`${DIR_CJS}/bytes.js`, `${HDR}
exports.lenIn = ${compressed.length};
exports.lenOut = ${data.length};
exports.bytes = '${base64}';
`);
fs.mkdirSync(DIR_ESM, { recursive: true });
fs.writeFileSync(`${DIR_ESM}/bytes.js`, `${HDR}
export const lenIn = ${compressed.length};

29
scripts/test-quantus-js.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Copyright 2026 @quantus/crypto authors & contributors
# SPDX-License-Identifier: Apache-2.0
# Runs the consumer test against a staged node_modules rather than in-place.
#
# In this repo `node_modules/@polkadot/wasm-util` symlinks to the package *source*
# directory, whose package.json carries no `exports` map — only the build output
# does. So a deep import like `@polkadot/wasm-util/base64` resolves for a real
# consumer and fails here, which would make the in-place test wrong in a way that
# has nothing to do with our package. Staging the built artifacts the way an
# install lays them out tests module resolution too, which is half of what can
# break in a published package.
set -e
PKG=packages/quantus-crypto
STAGE=$PKG/build-test
rm -rf $STAGE
mkdir -p $STAGE/node_modules/@quantus $STAGE/node_modules/@polkadot
cp -r $PKG/build $STAGE/node_modules/@quantus/crypto
cp -r packages/wasm-util/build $STAGE/node_modules/@polkadot/wasm-util
echo '{ "name": "quantus-crypto-consumer-test", "type": "module", "version": "0.0.0" }' > $STAGE/package.json
cp $PKG/test/consumer.mjs $STAGE/consumer.mjs
node $STAGE/consumer.mjs
rm -rf $STAGE

View File

@@ -610,6 +610,15 @@ __metadata:
languageName: node
linkType: hard
"@quantus/crypto@workspace:packages/quantus-crypto":
version: 0.0.0-use.local
resolution: "@quantus/crypto@workspace:packages/quantus-crypto"
dependencies:
"@polkadot/wasm-util": "npm:7.5.4"
tslib: "npm:^2.7.0"
languageName: unknown
linkType: soft
"@rollup/plugin-alias@npm:^5.1.1":
version: 5.1.1
resolution: "@rollup/plugin-alias@npm:5.1.1"