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:
@@ -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();
|
||||
Reference in New Issue
Block a user