Files
extension/scripts/tier1/chain.mjs
T
grenadeandClaude Opus 5 4f759c4094 test: the tier-1 cases that prove the plumbing, not the happy path
`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
2026-09-15 15:15:51 +03:00

152 lines
5.4 KiB
JavaScript

// Copyright 2019-2026 @polkadot/extension authors & contributors
// SPDX-License-Identifier: Apache-2.0
// The plumbing shared by the tier-1 scripts: a JSON-RPC transport, a runtime
// loaded from the chain's own metadata, and the three operations that need to be
// right — build an extrinsic, wait for it to be *included*, and read a balance.
//
// Nothing here uses @polkadot/api. WsProvider is a JSON-RPC client and decodes
// nothing; @quantus/codec produces and reads every byte. See quantus/api#1.
import { Runtime } from '@quantus/codec';
import { WsProvider } from '@polkadot/rpc-provider';
import { hexToU8a, u8aToHex } from '@polkadot/util';
import { blake2AsU8a, decodeAddress } from '@polkadot/util-crypto';
/**
* 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.
*/
export const HASH_ABOVE = 256;
export async function connect (endpoint) {
const provider = new WsProvider(endpoint);
await provider.isReady;
const rpc = (method, params = []) => provider.send(method, params);
const [version, genesisHash, metadataHex] = await Promise.all([
rpc('state_getRuntimeVersion'),
rpc('chain_getBlockHash', ['0x0']),
rpc('state_getMetadata')
]);
const runtime = Runtime.fromMetadata(hexToU8a(metadataHex));
return {
disconnect: () => provider.disconnect(),
genesisHash,
rawMetadata: metadataHex,
rpc,
runtime,
specName: version.specName,
specVersion: version.specVersion,
transactionVersion: version.transactionVersion
};
}
/**
* Build, sign and encode one extrinsic, with an immortal era.
*
* `signingContext` is exposed so a caller can get it deliberately wrong — that
* is a case worth proving, because a signature under the wrong FIPS 204 context
* is well-formed, verifies against its own key, and is refused only by the chain.
*/
export function build (chain, pair, call, nonce, { signingContext } = {}) {
const { genesisHash, runtime, specVersion, transactionVersion } = chain;
const values = runtime.standardExtensions({
blockHash: genesisHash,
eraHex: '0x00',
genesisHash,
nonce,
specVersion,
transactionVersion
});
const extra = runtime.encodeExtra(values);
const payload = runtime.signerPayload(call, values);
const toSign = payload.length > HASH_ABOVE ? blake2AsU8a(payload) : payload;
const signature = pair.sign(toSign, { context: signingContext, withType: true });
return {
extra,
hashed: payload.length > HASH_ABOVE,
hex: u8aToHex(runtime.encodeExtrinsic({ Id: u8aToHex(pair.addressRaw) }, signature, extra, call)),
payload,
signature
};
}
/**
* Submit, and wait until the extrinsic is in a block.
*
* **Not** by watching the sender's nonce. `system_accountNextIndex` counts
* pending pool transactions, so it advances the moment a transaction is
* accepted — which is before it has done anything. Waiting on it and then
* reading a balance shows the state from before the dispatch, and the result is
* a working transfer that reports itself as having moved nothing. That cost a
* confusing run: two `batch_all` calls were recorded as reverted when both had
* funded their target with exactly what they were asked to.
*
* So this 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 at all.
*/
export async function submitAndInclude (chain, hex, { timeoutMs = 10 * 60 * 1000 } = {}) {
const { rpc } = chain;
await rpc('author_submitExtrinsic', [hex]);
const deadline = Date.now() + timeoutMs;
let height = parseInt((await rpc('chain_getHeader')).number, 16);
while (Date.now() < deadline) {
const head = parseInt((await rpc('chain_getHeader')).number, 16);
while (height <= head) {
const blockHash = await rpc('chain_getBlockHash', [`0x${height.toString(16)}`]);
const block = await rpc('chain_getBlock', [blockHash]);
const index = block.block.extrinsics.indexOf(hex);
if (index !== -1) {
return { blockHash, height, index };
}
height++;
}
await new Promise((resolve) => setTimeout(resolve, 6_000));
}
throw new Error('accepted into the pool but not included before the deadline');
}
/** An account's free balance, from `System::Account`. */
export async function freeBalance (chain, address) {
const { rpc, runtime } = chain;
const target = runtime.storageTarget('System', 'Account', [u8aToHex(decodeAddress(address))]);
const raw = await rpc('state_getStorage', [target.key]);
// `null` from the node means the entry is unset, and for a `Default` entry
// that means the declared default — an account nobody has funded reads as a
// zero balance, not as an error.
const account = runtime.decodeStorage(target.valueTy, hexToU8a(raw ?? target.default));
return BigInt(account.data.free);
}
/**
* The next usable nonce, pending pool transactions included — which is what a
* signer wants, and is exactly not what an inclusion check wants.
*/
export async function nextNonce (chain, address) {
return parseInt(await chain.rpc('system_accountNextIndex', [address]), 10);
}
/** A `Balances.transfer_keep_alive` call. */
export function transfer (chain, to, value) {
return chain.runtime.encodeCall('Balances', 'transfer_keep_alive', {
dest: { Id: u8aToHex(decodeAddress(to)) },
value: value.toString()
});
}