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"