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
This commit is contained in:
2026-09-15 14:16:34 +03:00
parent 7fa26252f0
commit be69149957
6 changed files with 227 additions and 27 deletions

View File

@@ -52,13 +52,13 @@
},
"resolutions": {
"@polkadot/api": "^16.5.6",
"@polkadot/keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.1/keyring-14.0.3-quantus.1.tgz",
"@polkadot/networks": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.1/networks-14.0.3-quantus.1.tgz",
"@polkadot/keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.2/keyring-14.0.3-quantus.2.tgz",
"@polkadot/networks": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.2/networks-14.0.3-quantus.2.tgz",
"@polkadot/rpc-provider": "^16.5.6",
"@polkadot/types": "^16.5.6",
"@polkadot/ui-keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fui-keyring/-/3.16.7-quantus.1/ui-keyring-3.16.7-quantus.1.tgz",
"@polkadot/ui-keyring": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fui-keyring/-/3.16.7-quantus.2/ui-keyring-3.16.7-quantus.2.tgz",
"@polkadot/util": "^14.0.3",
"@polkadot/util-crypto": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Futil-crypto/-/14.0.3-quantus.1/util-crypto-14.0.3-quantus.1.tgz",
"@polkadot/util-crypto": "https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Futil-crypto/-/14.0.3-quantus.2/util-crypto-14.0.3-quantus.2.tgz",
"@polkadot/x-fetch": "^14.0.3",
"safe-buffer": "^5.2.1",
"typescript": "^5.5.4"

View File

@@ -34,6 +34,7 @@
"@polkadot/ui-settings": "^3.16.7",
"@polkadot/util": "^14.0.3",
"@polkadot/util-crypto": "^14.0.3",
"@quantus/codec": "^0.1.0",
"@quantus/crypto": "^0.1.1",
"eventemitter3": "^5.0.1",
"rxjs": "^7.8.1",

View File

@@ -3,10 +3,24 @@
import type { MetadataDefBase } from '@polkadot/extension-inject/types';
import { selectableNetworks } from '@polkadot/networks';
import { isQuantumSafe, selectableNetworks } from '@polkadot/networks';
// The chains this extension offers.
//
// Filtered here rather than in @polkadot/networks. An earlier attempt gated
// `selectableNetworks` itself, which broke @polkadot/api at import:
// @polkadot/types-known looks up every chain it holds upgrade history for and
// throws when one is missing. Those lists are a library's view of what Substrate
// networks exist; which of them a *wallet* offers is the wallet's decision, and
// this is the wallet.
//
// The filter is post-quantum accounts. Everything else in the registry uses a
// discrete-log scheme — *25519, Sr25519, Ed25519, secp256k1 — all of which Shor's
// algorithm breaks at once, so such a chain has no quantum-safe account type to
// offer whatever else is true of it. A null standardAccount counts as unsafe:
// unknown is not the same as safe.
const hashes: MetadataDefBase[] = selectableNetworks
.filter(({ genesisHash }) => !!genesisHash.length)
.filter(({ genesisHash, standardAccount }) => !!genesisHash.length && isQuantumSafe(standardAccount))
.map((network) => ({
chain: network.displayName,
genesisHash: network.genesisHash[0],

173
scripts/tier1/submit.mjs Normal file
View File

@@ -0,0 +1,173 @@
// 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();

View File

@@ -10,7 +10,8 @@
".prettierrc.cjs",
"eslint.config.js",
"i18next-scanner.config.cjs",
"rollup.config.js"
"rollup.config.js",
"scripts/**/*.mjs"
],
"exclude": [
"**/node_modules/**/*"

View File

@@ -982,6 +982,7 @@ __metadata:
"@polkadot/ui-settings": "npm:^3.16.7"
"@polkadot/util": "npm:^14.0.3"
"@polkadot/util-crypto": "npm:^14.0.3"
"@quantus/codec": "npm:^0.1.0"
"@quantus/crypto": "npm:^0.1.1"
eventemitter3: "npm:^5.0.1"
rxjs: "npm:^7.8.1"
@@ -1151,29 +1152,29 @@ __metadata:
languageName: unknown
linkType: soft
"@polkadot/keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.1/keyring-14.0.3-quantus.1.tgz":
version: 14.0.3-quantus.1
resolution: "@polkadot/keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.1/keyring-14.0.3-quantus.1.tgz"
"@polkadot/keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.2/keyring-14.0.3-quantus.2.tgz":
version: 14.0.3-quantus.2
resolution: "@polkadot/keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fkeyring/-/14.0.3-quantus.2/keyring-14.0.3-quantus.2.tgz"
dependencies:
"@polkadot/util": "npm:14.0.3"
"@polkadot/util-crypto": "npm:14.0.3-quantus.1"
"@polkadot/util-crypto": "npm:14.0.3-quantus.2"
"@quantus/crypto": "npm:^0.1.0"
tslib: "npm:^2.8.0"
peerDependencies:
"@polkadot/util": 14.0.3
"@polkadot/util-crypto": 14.0.3
checksum: 10/513818828aebd066ec618e114441bbd2aa9cb83527fce4a78867d3b9f982e4bb17527090642d704a2a8b1fd047276b30ce8c22efcdc10c81d3e318f4b42c7059
checksum: 10/e3885d6a75b4c006887d24dadbfc25fff562baae84d42fd2125878af60daecbf789deed12c975edfef0884a3caff4dd469dcdcd8b63eacbd1a5fbf8bbfe48815
languageName: node
linkType: hard
"@polkadot/networks@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.1/networks-14.0.3-quantus.1.tgz":
version: 14.0.3-quantus.1
resolution: "@polkadot/networks@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.1/networks-14.0.3-quantus.1.tgz"
"@polkadot/networks@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.2/networks-14.0.3-quantus.2.tgz":
version: 14.0.3-quantus.2
resolution: "@polkadot/networks@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fnetworks/-/14.0.3-quantus.2/networks-14.0.3-quantus.2.tgz"
dependencies:
"@polkadot/util": "npm:14.0.3"
"@substrate/ss58-registry": "npm:^1.51.0"
tslib: "npm:^2.8.0"
checksum: 10/5d93c35908eec5b41f29ffd4a5a79234c7329264e61d6b4346398e5b495b80109c5ce35a2f2bb95e9f6fc4eb22b9da047b9ca4191ec73a668c43da73861d7122
checksum: 10/3d2f0a08cde4be418d073f63587bed451a6ce4c91a8872a5e0c4191ff3e62d57a7942c1a1b6c183ed9c22742f5b95b7496fe4e32e3565baf2093d879ac51a55e
languageName: node
linkType: hard
@@ -1362,14 +1363,14 @@ __metadata:
languageName: node
linkType: hard
"@polkadot/ui-keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fui-keyring/-/3.16.7-quantus.1/ui-keyring-3.16.7-quantus.1.tgz":
version: 3.16.7-quantus.1
resolution: "@polkadot/ui-keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fui-keyring/-/3.16.7-quantus.1/ui-keyring-3.16.7-quantus.1.tgz"
"@polkadot/ui-keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fui-keyring/-/3.16.7-quantus.2/ui-keyring-3.16.7-quantus.2.tgz":
version: 3.16.7-quantus.2
resolution: "@polkadot/ui-keyring@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Fui-keyring/-/3.16.7-quantus.2/ui-keyring-3.16.7-quantus.2.tgz"
dependencies:
"@polkadot/keyring": "npm:14.0.3-quantus.1"
"@polkadot/keyring": "npm:14.0.3-quantus.2"
"@polkadot/ui-settings": "npm:3.16.7"
"@polkadot/util": "npm:^14.0.3"
"@polkadot/util-crypto": "npm:14.0.3-quantus.1"
"@polkadot/util-crypto": "npm:14.0.3-quantus.2"
mkdirp: "npm:^3.0.1"
rxjs: "npm:^7.8.1"
store: "npm:^2.0.12"
@@ -1378,7 +1379,7 @@ __metadata:
"@polkadot/keyring": "*"
"@polkadot/ui-settings": "*"
"@polkadot/util": "*"
checksum: 10/8f475e2fbbf795a82513e5195ec924f4c3077cb9cf6f09205db6507a814a527b0b99ba077e44ba506e3c8120366ae1f33371653858c7ed1f291bc1fd2917cb9f
checksum: 10/bd62eb7fb2ad152c8f6844938af34fa75fa6b359d5414cbaca0d77c80b808f8a4e51f62b223cbbb1cbe8feefab43b42db86dd978af92dcce3bab5236cdb3a885
languageName: node
linkType: hard
@@ -1411,13 +1412,13 @@ __metadata:
languageName: node
linkType: hard
"@polkadot/util-crypto@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Futil-crypto/-/14.0.3-quantus.1/util-crypto-14.0.3-quantus.1.tgz":
version: 14.0.3-quantus.1
resolution: "@polkadot/util-crypto@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Futil-crypto/-/14.0.3-quantus.1/util-crypto-14.0.3-quantus.1.tgz"
"@polkadot/util-crypto@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Futil-crypto/-/14.0.3-quantus.2/util-crypto-14.0.3-quantus.2.tgz":
version: 14.0.3-quantus.2
resolution: "@polkadot/util-crypto@https://git.lair.cafe/api/packages/quantus/npm/%40polkadot%2Futil-crypto/-/14.0.3-quantus.2/util-crypto-14.0.3-quantus.2.tgz"
dependencies:
"@noble/curves": "npm:^1.3.0"
"@noble/hashes": "npm:^1.3.3"
"@polkadot/networks": "npm:14.0.3-quantus.1"
"@polkadot/networks": "npm:14.0.3-quantus.2"
"@polkadot/util": "npm:14.0.3"
"@polkadot/wasm-crypto": "npm:^7.5.3"
"@polkadot/wasm-util": "npm:^7.5.3"
@@ -1429,7 +1430,7 @@ __metadata:
tslib: "npm:^2.8.0"
peerDependencies:
"@polkadot/util": 14.0.3
checksum: 10/ae364a2c47ea6033222f34766f83f3c3740966f83f380978b716013d4ab3862f03355034afe6ef84e7f28c90bda1eb347114c342acd5f4844566580d13d34341
checksum: 10/78f04147ddfe8ae0bca4cba2f235715615a140de9ab95f10d6e4bbabee4a2865e6f8a84f70668722ab688094a8519eeb019ed5122620b355a3ea40b0d8cfdeb8
languageName: node
linkType: hard
@@ -1602,6 +1603,16 @@ __metadata:
languageName: node
linkType: hard
"@quantus/codec@npm:^0.1.0":
version: 0.1.0
resolution: "@quantus/codec@npm:0.1.0::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcodec%2F-%2F0.1.0%2Fcodec-0.1.0.tgz"
dependencies:
fflate: "npm:^0.8.2"
tslib: "npm:^2.7.0"
checksum: 10/90d40e0909383f5f0fea3f662772702d78f1b6fbecb9e8cac901e8a20b5adafff97782a4c13faa7e2278abd97c3c12275695d2a21d19790a359df01267febe84
languageName: node
linkType: hard
"@quantus/crypto@npm:^0.1.0, @quantus/crypto@npm:^0.1.1":
version: 0.1.1
resolution: "@quantus/crypto@npm:0.1.1::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcrypto%2F-%2F0.1.1%2Fcrypto-0.1.1.tgz"