`cases.mjs`, against Heisenberg at spec 148. All five pass: PASS the empty signing context is rejected — 1010: bad signature PASS a payload over 256 bytes is hashed before signing — 279 -> blake2 32 PASS a batch_all over 256 bytes dispatches — block 1050692, funded 50 HEI PASS an ML-DSA-65 account signs and is accepted — block 1050693 PASS an account exported to JSON and re-imported still signs — block 1050694 The first is the one worth having. Every other failure mode here surfaces as a decode error somewhere; a signature under the wrong FIPS 204 context is well-formed, verifies against its own key, and is refused only by the runtime — so nothing local can tell it from a correct one. **`submitAndInclude` does not watch the nonce**, and the reason is a mistake this harness made first. `system_accountNextIndex` counts pending pool transactions, so it advances the moment a transaction is *accepted* — before it has done anything. Waiting on it and then reading a balance shows the state from before the dispatch. Two `batch_all` calls were recorded as reverted on that basis when both had funded their target with exactly what they were asked for; the accounts were sitting there holding 50 HEI each while the run said they held nothing. It now looks for the extrinsic's own bytes in the blocks that arrive, which also exercises the decoder against real chain data — every Quantus block opens with a bare-v5 timestamp inherent that @polkadot/api cannot read. `submit.mjs` moves onto the same shared `chain.mjs` and now *asserts* the recipient's balance moved rather than printing it, which it could not do before: reading a balance needs storage addressing, which @quantus/codec only gained for this (quantus/wasm#4). Refs #7 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
152 lines
6.1 KiB
JavaScript
152 lines
6.1 KiB
JavaScript
// Copyright 2019-2026 @polkadot/extension authors & contributors
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
// quantus/extension#7 tier 1, the cases that prove the plumbing rather than the
|
|
// happy path. `submit.mjs` shows one transfer working; this asks the chain the
|
|
// questions whose answers cannot be checked locally.
|
|
//
|
|
// 1. the empty signing context is REJECTED
|
|
// 2. a payload over 256 bytes is signed as its BLAKE2b hash and dispatches
|
|
// 3. an ML-DSA-65 account signs and is accepted (submit.mjs covers 87)
|
|
// 4. an account exported to JSON and re-imported still signs
|
|
//
|
|
// The first is the important one. Every other failure here shows up as a decode
|
|
// error somewhere; a signature under the wrong context is well-formed, verifies
|
|
// against its own key, and is refused only by the runtime.
|
|
|
|
import { contextForSpec } from '@quantus/crypto';
|
|
|
|
import { Keyring } from '@polkadot/keyring';
|
|
import { u8aToHex } from '@polkadot/util';
|
|
import { cryptoWaitReady, mnemonicGenerate } from '@polkadot/util-crypto';
|
|
|
|
import { build, connect, freeBalance, nextNonce, submitAndInclude, transfer } from './chain.mjs';
|
|
|
|
const ENDPOINT = process.env.QUANTUS_WS || 'wss://a1-heisenberg.quantus.cat';
|
|
const ALICE_SEED = `0x${'00'.repeat(32)}`;
|
|
const AMOUNT = 1_000_000_000n; // 0.001
|
|
// Far more than the ~0.008 a 5.3 KiB ML-DSA-65 extrinsic costs, and comfortably
|
|
// over the existential deposit.
|
|
const FUNDING = 50_000_000_000_000n;
|
|
|
|
let failures = 0;
|
|
|
|
function report (name, ok, detail) {
|
|
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
|
|
|
|
if (!ok) {
|
|
failures++;
|
|
}
|
|
}
|
|
|
|
await cryptoWaitReady();
|
|
|
|
const chain = await connect(ENDPOINT);
|
|
const { runtime, specVersion } = chain;
|
|
const context = contextForSpec(specVersion);
|
|
|
|
console.log(`chain ${chain.specName} spec ${specVersion} tx ${chain.transactionVersion}`);
|
|
console.log(`context ${new TextDecoder().decode(context)}`);
|
|
|
|
const keyring = new Keyring({ ss58Format: 189, type: 'dilithium87' });
|
|
const alice = keyring.createFromUri(ALICE_SEED, { name: 'crystal_alice' }, 'dilithium87');
|
|
const fresh = keyring.createFromUri(mnemonicGenerate(12), { name: 'tier1-65' }, 'dilithium65');
|
|
|
|
console.log(`alice ${alice.address}`);
|
|
console.log(`ml-dsa-65 ${fresh.address}`);
|
|
console.log('');
|
|
|
|
// --- 1. the empty signing context is rejected ------------------------------
|
|
// No block needed: the node verifies the signature before accepting into the
|
|
// pool, which is exactly why this is worth asserting.
|
|
{
|
|
const nonce = await nextNonce(chain, alice.address);
|
|
const { hex } = build(chain, alice, transfer(chain, alice.address, AMOUNT), nonce, { signingContext: new Uint8Array() });
|
|
|
|
try {
|
|
await chain.rpc('author_submitExtrinsic', [hex]);
|
|
report('the empty signing context is rejected', false, 'the chain ACCEPTED it');
|
|
} catch (error) {
|
|
report('the empty signing context is rejected', /bad signature/i.test(error.message), error.message);
|
|
}
|
|
}
|
|
|
|
// --- 2. a payload past the hashing threshold, which also funds the 65 account
|
|
{
|
|
const nonce = await nextNonce(chain, alice.address);
|
|
const send = (to, value) => ({
|
|
Balances: { transfer_keep_alive: { dest: { Id: u8aToHex(to) }, value: value.toString() } }
|
|
});
|
|
// Five transfers in one batch_all, to push the signing payload over 256 bytes
|
|
// so the BLAKE2b branch is the one that runs. A call nested inside a call also
|
|
// exercises the codec at a depth a plain transfer never reaches.
|
|
const call = runtime.encodeCall('Utility', 'batch_all', {
|
|
calls: [
|
|
send(fresh.addressRaw, FUNDING),
|
|
send(alice.addressRaw, AMOUNT),
|
|
send(alice.addressRaw, AMOUNT),
|
|
send(alice.addressRaw, AMOUNT),
|
|
send(alice.addressRaw, AMOUNT)
|
|
]
|
|
});
|
|
const { hashed, hex, payload } = build(chain, alice, call, nonce, { signingContext: context });
|
|
|
|
report('a payload over 256 bytes is hashed before signing', hashed, `${payload.length} bytes -> blake2 32`);
|
|
|
|
try {
|
|
const { height } = await submitAndInclude(chain, hex);
|
|
// `batch_all` is atomic, so "included" and "did what it said" are different
|
|
// claims. The balance is the one that matters.
|
|
const funded = await freeBalance(chain, fresh.address);
|
|
|
|
report('a batch_all over 256 bytes dispatches', funded === FUNDING, `block ${height}, ${fresh.address} holds ${funded}`);
|
|
} catch (error) {
|
|
report('a batch_all over 256 bytes dispatches', false, error.message);
|
|
}
|
|
}
|
|
|
|
// --- 3. an ML-DSA-65 account signs -----------------------------------------
|
|
{
|
|
const nonce = await nextNonce(chain, fresh.address);
|
|
const { hex } = build(chain, fresh, transfer(chain, alice.address, AMOUNT), nonce, { signingContext: context });
|
|
|
|
try {
|
|
const before = await freeBalance(chain, alice.address);
|
|
const { height } = await submitAndInclude(chain, hex);
|
|
const moved = await freeBalance(chain, alice.address) - before;
|
|
|
|
report('an ML-DSA-65 account signs and is accepted', moved === AMOUNT, `block ${height}, moved ${moved}`);
|
|
} catch (error) {
|
|
report('an ML-DSA-65 account signs and is accepted', false, error.message);
|
|
}
|
|
}
|
|
|
|
// --- 4. exported to JSON, re-imported, still signs -------------------------
|
|
{
|
|
const password = 'tier1-round-trip';
|
|
const json = fresh.toJson(password);
|
|
const reimported = keyring.createFromJson(json);
|
|
|
|
reimported.decodePkcs8(password);
|
|
|
|
const sameAddress = reimported.address === fresh.address;
|
|
const nonce = await nextNonce(chain, reimported.address);
|
|
const { hex } = build(chain, reimported, transfer(chain, alice.address, AMOUNT), nonce, { signingContext: context });
|
|
|
|
try {
|
|
const before = await freeBalance(chain, alice.address);
|
|
const { height } = await submitAndInclude(chain, hex);
|
|
const moved = await freeBalance(chain, alice.address) - before;
|
|
|
|
report('an account exported to JSON and re-imported still signs', sameAddress && moved === AMOUNT, `block ${height}, address preserved=${sameAddress}, moved ${moved}`);
|
|
} catch (error) {
|
|
report('an account exported to JSON and re-imported still signs', false, error.message);
|
|
}
|
|
}
|
|
|
|
await chain.disconnect();
|
|
|
|
console.log('');
|
|
console.log(failures ? `${failures} case(s) failed` : 'all cases passed');
|
|
process.exit(failures ? 1 : 0);
|