Files
extension/scripts/tier1/submit.mjs
T
grenadeandClaude Opus 5 be69149957 feat: build and decode extrinsics with @quantus/codec, and prove tier 1
The tier-1 harness now signs a real transfer and gets it into a real block:

    accepted    0xd25bb081e4a87da6a2af79f038f6991e914d732322393096128894c0d644e806
    included    block 1050475 index 1
    decoded     {"Balances":{"transfer_keep_alive":{…,"value":"1000000000"}}}
    nonce       2761 -> 2762

on Heisenberg at spec 148, signed by the forked keyring under QUANTUS_EXTRINSIC.
First end-to-end proof that the fork can spend.

It got there by dropping @polkadot/api entirely. Every byte on the wire is now
produced by @quantus/codec from metadata the node generated by running
Metadata_metadata against the runtime WASM; WsProvider appears only as a
JSON-RPC transport. The api route failed three times over — a 2048-byte cap on
fixed arrays, a preamble byte read as a version so that no Quantus block decodes
at all, and signed extensions *assumed* to be empty rather than read — and
quantus/api#1 has the evidence. The first submission through it was rejected as
`1010: Invalid Transaction: Transaction has a bad signature`; through the codec
the same transfer is accepted.

The harness reads the block back with the same runtime that built the extrinsic,
which also exercises what @polkadot/api cannot do at all: index 0 of every
Quantus block is a timestamp inherent whose preamble byte is 0x05 — bare,
version 5 — beside this signed extrinsic's 0x84, while the metadata declares
version 4.

Also moves the post-quantum chain gate here from @polkadot/networks. Gating the
library list broke @polkadot/api at import, because @polkadot/types-known throws
for a chain it holds upgrade history for but cannot find. What a wallet offers
is the wallet's decision, and this is the wallet.

Refs #7, quantus/api#1, quantus/wasm#3

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

174 lines
6.9 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.
//
// Nothing here uses @polkadot/api. Its codec cannot read or write this chain —
// quantus/api#1 has the evidence — so every byte on the wire is produced by
// @quantus/codec against metadata the node generated by running
// `Metadata_metadata` against the runtime WASM. `WsProvider` appears only as a
// JSON-RPC transport; no type in this file is decoded by it.
import { Runtime } from '@quantus/codec';
import { contextForSpec } from '@quantus/crypto';
import { Keyring } from '@polkadot/keyring';
import { WsProvider } from '@polkadot/rpc-provider';
import { u8aToHex } from '@polkadot/util';
import { blake2AsU8a, cryptoWaitReady, decodeAddress } from '@polkadot/util-crypto';
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';
// Substrate's own rule, from `unchecked_extrinsic.rs`: a signing payload longer
// than 256 bytes is signed as its BLAKE2b-256 hash, otherwise as-is.
const HASH_ABOVE = 256;
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 provider = new WsProvider(ENDPOINT);
await provider.isReady;
const [version, genesisHash, metadataHex] = await Promise.all([
provider.send('state_getRuntimeVersion', []),
provider.send('chain_getBlockHash', ['0x0']),
provider.send('state_getMetadata', [])
]);
const specVersion = version.specVersion;
const transactionVersion = version.transactionVersion;
const runtime = Runtime.fromMetadata(Uint8Array.from(Buffer.from(metadataHex.slice(2), 'hex')));
console.log(`chain ${version.specName} spec ${specVersion} tx ${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 destId = u8aToHex(decodeAddress(dest));
const nonce = parseInt(await provider.send('system_accountNextIndex', [pair.address]), 10);
console.log(`recipient ${dest}`);
console.log(`nonce ${nonce}`);
const call = runtime.encodeCall('Balances', 'transfer_keep_alive', {
dest: { Id: destId },
value: amount.toString()
});
console.log(`call ${u8aToHex(call)}`);
// An immortal era, so `CheckMortality`'s implicit is the genesis hash and there
// is no birth block to agree with the node about. Mortality is a hardening step,
// not part of proving the fork can spend.
const values = runtime.standardExtensions({
blockHash: genesisHash,
genesisHash,
nonce,
specVersion,
transactionVersion
});
// Every extension the runtime declares as non-empty and `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) && !values[e.identifier]);
if (unmet.length) {
throw new Error(`runtime declares extensions this harness cannot fill: ${unmet.map((e) => e.identifier).join(', ')}`);
}
const extra = runtime.encodeExtra(values);
const payload = runtime.signerPayload(call, values);
const toSign = payload.length > HASH_ABOVE ? blake2AsU8a(payload) : payload;
const context = contextForSpec(specVersion);
console.log(`extra ${u8aToHex(extra)}`);
console.log(`payload ${payload.length} bytes -> signing ${toSign.length}`);
console.log(`context ${context.length ? new TextDecoder().decode(context) : '(empty)'}`);
const signature = pair.sign(toSign, { context, withType: true });
console.log(`signature ${signature.length} bytes, variant 0x${signature[0].toString(16).padStart(2, '0')}`);
const extrinsic = runtime.encodeExtrinsic({ Id: u8aToHex(pair.addressRaw) }, signature, extra, call);
console.log(`extrinsic ${extrinsic.length} 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 this package; one that succeeds and is
// still rejected is a disagreement with the chain, and the two are worth telling
// apart before the node is involved.
const readBack = runtime.decodeExtrinsic(extrinsic);
console.log(`round trip v${readBack.version} signed=${readBack.signed} ${JSON.stringify(readBack.call)}`);
const hash = await provider.send('author_submitExtrinsic', [u8aToHex(extrinsic)]);
// Acceptance into the pool already means the signature verified: an extrinsic
// the runtime cannot check is rejected right here, as `1010: Invalid
// Transaction: Transaction has a bad signature`.
console.log(`accepted ${hash}`);
// Inclusion, though, is the claim worth making, so wait for the chain to put it
// in a block and then read that block back with the same runtime — which also
// exercises the thing @polkadot/api cannot do at all: every Quantus block opens
// with a timestamp inherent whose preamble byte is `0x05`, bare and version 5,
// while this signed extrinsic is `0x84`, signed and version 4.
const DEADLINE = Date.now() + 10 * 60 * 1000;
let height = parseInt((await provider.send('chain_getHeader', [])).number, 16);
let found = null;
while (!found && Date.now() < DEADLINE) {
const head = parseInt((await provider.send('chain_getHeader', [])).number, 16);
while (height <= head && !found) {
const blockHash = await provider.send('chain_getBlockHash', [`0x${height.toString(16)}`]);
const block = await provider.send('chain_getBlock', [blockHash]);
for (const [index, raw] of block.block.extrinsics.entries()) {
const decoded = runtime.decodeExtrinsic(Uint8Array.from(Buffer.from(raw.slice(2), 'hex')));
if (raw === u8aToHex(extrinsic)) {
found = { blockHash, decoded, height, index };
}
}
height++;
}
if (!found) {
await new Promise((resolve) => setTimeout(resolve, 10_000));
}
}
if (!found) {
throw new Error('accepted into the pool but not included within ten minutes');
}
console.log(`included block ${found.height} index ${found.index} (${found.blockHash})`);
console.log(`decoded ${JSON.stringify(found.decoded.call)}`);
console.log(`signature ${JSON.stringify(found.decoded.signature).slice(0, 60)}…`);
const after = parseInt(await provider.send('system_accountNextIndex', [pair.address]), 10);
console.log(`nonce ${nonce} -> ${after}`);
if (after !== nonce + 1) {
throw new Error(`nonce did not advance: expected ${nonce + 1}, got ${after}`);
}
await provider.disconnect();