Files
wasm/packages/quantus-crypto/test/consumer.mjs
T
grenadeandClaude Opus 5 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

77 lines
4.3 KiB
JavaScript

// 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, WormholeBranch, wormholeAddresses, wormholeNullifiers
} 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);
// wormhole: the chain node's TEST_WORMHOLE_ADDRESS (qzpWh4AEtsgCyEbv4WBgFWnB9bcdF2L2jVDuyjXP9mSTyBaeU)
const NODE_PHRASE = 'orchard answer curve patient visual flower maze noise retreat penalty cage small earth domain scan pitch bottom crunch theme club client swap slice raven';
const wh = wormholeAddresses(NODE_PHRASE, '', 0, WormholeBranch.Receive, 0, 2);
eq('wormhole address count', wh.length, 2);
eq('wormhole receive 0 is the node test address',
Buffer.from(wh[0]).toString('hex'),
'dfcfd6e59c75d208e84f54a887537bcf7b04265790ec79960bf49de123404d0e');
eq('change branch differs', Buffer.from(wormholeAddresses(NODE_PHRASE, '', 0, WormholeBranch.Change, 0, 1)[0]).toString('hex') !== Buffer.from(wh[0]).toString('hex'), true);
// nullifiers: shape, determinism, and the cost of a wallet account's precompute
const n = wormholeNullifiers(NODE_PHRASE, '', 0, WormholeBranch.Receive, 0, 2, 7, 3);
eq('nullifier shape', `${n.length}x${n[0].length}x${n[0][0].length}`, '2x3x32');
eq('nullifiers differ by count', Buffer.from(n[0][0]).equals(Buffer.from(n[0][1])), false);
eq('nullifiers differ by address', Buffer.from(n[0][0]).equals(Buffer.from(n[1][0])), false);
eq('a sub-range agrees', Buffer.from(wormholeNullifiers(NODE_PHRASE, '', 0, WormholeBranch.Receive, 1, 1, 8, 1)[0][0]).toString('hex'), Buffer.from(n[1][1]).toString('hex'));
const t0 = performance.now();
wormholeNullifiers(NODE_PHRASE, '', 0, WormholeBranch.Receive, 0, 20, 0, 256);
wormholeNullifiers(NODE_PHRASE, '', 0, WormholeBranch.Change, 0, 20, 0, 256);
console.log(` 40 addresses x 256 counts: ${Math.round(performance.now() - t0)} ms`);
process.exit(fail ? 1 : 0);