Files
extension/scripts/tier1/submit.mjs
rob thijssen f61cfd4ed5 chore: let the tier-1 harness send a configurable amount
`QUANTUS_AMOUNT`, in plancks. The hardcoded 0.001 HEI was right for proving a
transfer lands and useless for funding an account to sign *from*: one ML-DSA
extrinsic costs about 0.01 HEI in fees, so an account funded with the default
could not pay for its own first transaction.

Needed for the manual verification pass, which has to fund a fresh account
created in the extension — the only funded dev account (crystal_alice) is
ML-DSA-87, and the import-seed screen always imports as ML-DSA-65, so the
funded account cannot be brought in through the UI at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
2026-09-16 11:47:24 +03:00

103 lines
4.6 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
// In plancks: 12 decimal places, so 1_000_000_000_000 is 1 HEI. The default is
// deliberately tiny for the happy-path proof; funding an account you intend to
// sign from needs far more, since one ML-DSA extrinsic costs about 0.01 HEI in
// fees alone.
const amount = BigInt(process.env.QUANTUS_AMOUNT || '1000000000'); // default 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}`);
}