`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
99 lines
4.3 KiB
JavaScript
99 lines
4.3 KiB
JavaScript
// Copyright 2019-2026 @polkadot/extension authors & contributors
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
// quantus/extension#7 tier 1: sign a real extrinsic with the forked keyring and
|
|
// submit it to a real node. No browser, no extension, no dapp.
|
|
//
|
|
// This is the happy path, printed at every step. `cases.mjs` next to it is the
|
|
// set that proves the plumbing — the empty signing context being rejected, a
|
|
// payload past the BLAKE2b threshold, an ML-DSA-65 account, a JSON round trip.
|
|
//
|
|
// Nothing here uses @polkadot/api. Its codec can neither read nor write this
|
|
// chain — quantus/api#1 has the evidence — so every byte is produced by
|
|
// @quantus/codec against metadata the node generated by running
|
|
// `Metadata_metadata` against the runtime WASM.
|
|
|
|
import { contextForSpec } from '@quantus/crypto';
|
|
|
|
import { Keyring } from '@polkadot/keyring';
|
|
import { u8aToHex } from '@polkadot/util';
|
|
import { cryptoWaitReady } 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';
|
|
// crystal_alice: seed = 32 zero bytes, ML-DSA-87. Published in the chain's own
|
|
// source, funded at dev genesis, and assumed compromised by everyone.
|
|
const SEED = `0x${'00'.repeat(32)}`;
|
|
const TYPE = 'dilithium87';
|
|
|
|
await cryptoWaitReady();
|
|
|
|
const keyring = new Keyring({ ss58Format: 189, type: TYPE });
|
|
const pair = keyring.createFromUri(SEED, { name: 'crystal_alice' }, TYPE);
|
|
|
|
console.log(`signer ${pair.address}`);
|
|
|
|
const chain = await connect(ENDPOINT);
|
|
const { genesisHash, runtime, specVersion } = chain;
|
|
|
|
console.log(`chain ${chain.specName} spec ${specVersion} tx ${chain.transactionVersion}`);
|
|
console.log(`genesis ${genesisHash}`);
|
|
console.log(`extrinsic v${runtime.extrinsicVersion}`);
|
|
|
|
const dest = process.env.QUANTUS_DEST || 'qzkYEQv8tQsmniZYdame3Cku18RL5g9bGK9Pdydq5TMPdpE3y'; // crystal_bob
|
|
const amount = 1_000_000_000n; // 0.001
|
|
const nonce = await nextNonce(chain, pair.address);
|
|
const before = await freeBalance(chain, dest);
|
|
|
|
console.log(`recipient ${dest}`);
|
|
console.log(`before ${before}`);
|
|
console.log(`nonce ${nonce}`);
|
|
|
|
const call = transfer(chain, dest, amount);
|
|
|
|
console.log(`call ${u8aToHex(call)}`);
|
|
|
|
// Every extension the runtime declares as non-empty that `standardExtensions`
|
|
// does not cover. Reported rather than skipped: skipping is the polkadot-js
|
|
// failure mode this whole package exists to avoid.
|
|
const unmet = runtime
|
|
.signedExtensions()
|
|
.filter((e) => (e.needsExtra || e.needsAdditional) && !['ChargeTransactionPayment', 'CheckGenesis', 'CheckMetadataHash', 'CheckMortality', 'CheckNonce', 'CheckSpecVersion', 'CheckTxVersion'].includes(e.identifier));
|
|
|
|
if (unmet.length) {
|
|
throw new Error(`runtime declares extensions this harness cannot fill: ${unmet.map((e) => e.identifier).join(', ')}`);
|
|
}
|
|
|
|
const context = contextForSpec(specVersion);
|
|
const { extra, hashed, hex, payload, signature } = build(chain, pair, call, nonce, { signingContext: context });
|
|
|
|
console.log(`extra ${u8aToHex(extra)}`);
|
|
console.log(`payload ${payload.length} bytes -> signing ${hashed ? '32 (blake2)' : payload.length}`);
|
|
console.log(`context ${context.length ? new TextDecoder().decode(context) : '(empty)'}`);
|
|
console.log(`signature ${signature.length} bytes, variant 0x${signature[0].toString(16).padStart(2, '0')}`);
|
|
console.log(`extrinsic ${(hex.length - 2) / 2} bytes`);
|
|
|
|
// Decoding what we are about to send, with the same runtime that built it. A
|
|
// round trip that fails here is a bug in the codec; one that succeeds and is
|
|
// still rejected is a disagreement with the chain, and telling those apart is
|
|
// most of the work.
|
|
const readBack = runtime.decodeExtrinsic(Uint8Array.from(Buffer.from(hex.slice(2), 'hex')));
|
|
|
|
console.log(`round trip v${readBack.version} signed=${readBack.signed} ${JSON.stringify(readBack.call)}`);
|
|
|
|
const { blockHash, height, index } = await submitAndInclude(chain, hex);
|
|
|
|
console.log(`included block ${height} index ${index} (${blockHash})`);
|
|
|
|
const after = await freeBalance(chain, dest);
|
|
|
|
console.log(`after ${after}`);
|
|
console.log(`delta ${after - before} (expected ${amount})`);
|
|
|
|
await chain.disconnect();
|
|
|
|
if (after - before !== amount) {
|
|
throw new Error(`recipient balance moved by ${after - before}, expected ${amount}`);
|
|
}
|