From b882f914e7ebba4617ba08a8416c899efca12f53 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Thu, 10 Sep 2026 14:17:15 +0300 Subject: [PATCH] build(quantus-crypto): buildable, installable and tested as a package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f --- .gitignore | 2 + package.json | 6 +- packages/quantus-crypto/src/cjs/bytes.d.ts | 6 -- packages/quantus-crypto/src/cjs/bytes.js | 10 ---- packages/quantus-crypto/src/cjs/package.json | 3 - packages/quantus-crypto/src/init.ts | 7 ++- packages/quantus-crypto/test/consumer.mjs | 56 +++++++++++++++++++ packages/quantus-crypto/tsconfig.build.json | 26 +++++++++ scripts/build-quantus-js.sh | 58 ++++++++++++++++++++ scripts/build-quantus.sh | 18 +++++- scripts/install-build-deps.sh | 12 ++++ scripts/pack-quantus-base.mjs | 21 ++----- scripts/test-quantus-js.sh | 29 ++++++++++ yarn.lock | 9 +++ 14 files changed, 225 insertions(+), 38 deletions(-) delete mode 100644 packages/quantus-crypto/src/cjs/bytes.d.ts delete mode 100644 packages/quantus-crypto/src/cjs/bytes.js delete mode 100644 packages/quantus-crypto/src/cjs/package.json create mode 100644 packages/quantus-crypto/test/consumer.mjs create mode 100644 packages/quantus-crypto/tsconfig.build.json create mode 100755 scripts/build-quantus-js.sh create mode 100755 scripts/test-quantus-js.sh diff --git a/.gitignore b/.gitignore index 1f720c0b..c9d63836 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ binaryen/ +binaryen-quantus/ bindgen/ bindgen-quantus/ build/ build-*/ +build-test/ bytes/ coverage/ node_modules/ diff --git a/package.json b/package.json index adc5afb8..a4911a44 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/quantus-crypto/src/cjs/bytes.d.ts b/packages/quantus-crypto/src/cjs/bytes.d.ts deleted file mode 100644 index 92d7bbe1..00000000 --- a/packages/quantus-crypto/src/cjs/bytes.d.ts +++ /dev/null @@ -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; diff --git a/packages/quantus-crypto/src/cjs/bytes.js b/packages/quantus-crypto/src/cjs/bytes.js deleted file mode 100644 index 1d148f16..00000000 --- a/packages/quantus-crypto/src/cjs/bytes.js +++ /dev/null @@ -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 = ''; diff --git a/packages/quantus-crypto/src/cjs/package.json b/packages/quantus-crypto/src/cjs/package.json deleted file mode 100644 index 5bbefffb..00000000 --- a/packages/quantus-crypto/src/cjs/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "commonjs" -} diff --git a/packages/quantus-crypto/src/init.ts b/packages/quantus-crypto/src/init.ts index 4b28a9e2..eba41cc4 100644 --- a/packages/quantus-crypto/src/init.ts +++ b/packages/quantus-crypto/src/init.ts @@ -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'; diff --git a/packages/quantus-crypto/test/consumer.mjs b/packages/quantus-crypto/test/consumer.mjs new file mode 100644 index 00000000..b7d70d72 --- /dev/null +++ b/packages/quantus-crypto/test/consumer.mjs @@ -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); diff --git a/packages/quantus-crypto/tsconfig.build.json b/packages/quantus-crypto/tsconfig.build.json new file mode 100644 index 00000000..23481326 --- /dev/null +++ b/packages/quantus-crypto/tsconfig.build.json @@ -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": [] +} diff --git a/scripts/build-quantus-js.sh b/scripts/build-quantus-js.sh new file mode 100755 index 00000000..c909dc31 --- /dev/null +++ b/scripts/build-quantus-js.sh @@ -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" diff --git a/scripts/build-quantus.sh b/scripts/build-quantus.sh index 1fb1ffe3..18497240 100755 --- a/scripts/build-quantus.sh +++ b/scripts/build-quantus.sh @@ -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 diff --git a/scripts/install-build-deps.sh b/scripts/install-build-deps.sh index db5f061a..877376e1 100755 --- a/scripts/install-build-deps.sh +++ b/scripts/install-build-deps.sh @@ -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 diff --git a/scripts/pack-quantus-base.mjs b/scripts/pack-quantus-base.mjs index 1d2ae08a..00385bf0 100644 --- a/scripts/pack-quantus-base.mjs +++ b/scripts/pack-quantus-base.mjs @@ -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}; diff --git a/scripts/test-quantus-js.sh b/scripts/test-quantus-js.sh new file mode 100755 index 00000000..d2036026 --- /dev/null +++ b/scripts/test-quantus-js.sh @@ -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 diff --git a/yarn.lock b/yarn.lock index f4967a6a..b8539e99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"