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
77 lines
4.3 KiB
JavaScript
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);
|