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
This commit is contained in:
@@ -34,7 +34,7 @@
|
||||
"@polkadot/ui-settings": "^3.16.7",
|
||||
"@polkadot/util": "^14.0.3",
|
||||
"@polkadot/util-crypto": "^14.0.3",
|
||||
"@quantus/codec": "^0.2.0",
|
||||
"@quantus/codec": "^0.4.0",
|
||||
"@quantus/crypto": "^0.1.1",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"rxjs": "^7.8.1",
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@polkadot/networks": "^14.0.3",
|
||||
"@polkadot/util": "^14.0.3",
|
||||
"@polkadot/util-crypto": "^14.0.3",
|
||||
"@quantus/codec": "^0.2.0",
|
||||
"@quantus/codec": "^0.4.0",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
151
scripts/tier1/cases.mjs
Normal file
151
scripts/tier1/cases.mjs
Normal file
@@ -0,0 +1,151 @@
|
||||
// Copyright 2019-2026 @polkadot/extension authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// quantus/extension#7 tier 1, the cases that prove the plumbing rather than the
|
||||
// happy path. `submit.mjs` shows one transfer working; this asks the chain the
|
||||
// questions whose answers cannot be checked locally.
|
||||
//
|
||||
// 1. the empty signing context is REJECTED
|
||||
// 2. a payload over 256 bytes is signed as its BLAKE2b hash and dispatches
|
||||
// 3. an ML-DSA-65 account signs and is accepted (submit.mjs covers 87)
|
||||
// 4. an account exported to JSON and re-imported still signs
|
||||
//
|
||||
// The first is the important one. Every other failure here shows up as a decode
|
||||
// error somewhere; a signature under the wrong context is well-formed, verifies
|
||||
// against its own key, and is refused only by the runtime.
|
||||
|
||||
import { contextForSpec } from '@quantus/crypto';
|
||||
|
||||
import { Keyring } from '@polkadot/keyring';
|
||||
import { u8aToHex } from '@polkadot/util';
|
||||
import { cryptoWaitReady, mnemonicGenerate } 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';
|
||||
const ALICE_SEED = `0x${'00'.repeat(32)}`;
|
||||
const AMOUNT = 1_000_000_000n; // 0.001
|
||||
// Far more than the ~0.008 a 5.3 KiB ML-DSA-65 extrinsic costs, and comfortably
|
||||
// over the existential deposit.
|
||||
const FUNDING = 50_000_000_000_000n;
|
||||
|
||||
let failures = 0;
|
||||
|
||||
function report (name, ok, detail) {
|
||||
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`);
|
||||
|
||||
if (!ok) {
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
await cryptoWaitReady();
|
||||
|
||||
const chain = await connect(ENDPOINT);
|
||||
const { runtime, specVersion } = chain;
|
||||
const context = contextForSpec(specVersion);
|
||||
|
||||
console.log(`chain ${chain.specName} spec ${specVersion} tx ${chain.transactionVersion}`);
|
||||
console.log(`context ${new TextDecoder().decode(context)}`);
|
||||
|
||||
const keyring = new Keyring({ ss58Format: 189, type: 'dilithium87' });
|
||||
const alice = keyring.createFromUri(ALICE_SEED, { name: 'crystal_alice' }, 'dilithium87');
|
||||
const fresh = keyring.createFromUri(mnemonicGenerate(12), { name: 'tier1-65' }, 'dilithium65');
|
||||
|
||||
console.log(`alice ${alice.address}`);
|
||||
console.log(`ml-dsa-65 ${fresh.address}`);
|
||||
console.log('');
|
||||
|
||||
// --- 1. the empty signing context is rejected ------------------------------
|
||||
// No block needed: the node verifies the signature before accepting into the
|
||||
// pool, which is exactly why this is worth asserting.
|
||||
{
|
||||
const nonce = await nextNonce(chain, alice.address);
|
||||
const { hex } = build(chain, alice, transfer(chain, alice.address, AMOUNT), nonce, { signingContext: new Uint8Array() });
|
||||
|
||||
try {
|
||||
await chain.rpc('author_submitExtrinsic', [hex]);
|
||||
report('the empty signing context is rejected', false, 'the chain ACCEPTED it');
|
||||
} catch (error) {
|
||||
report('the empty signing context is rejected', /bad signature/i.test(error.message), error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2. a payload past the hashing threshold, which also funds the 65 account
|
||||
{
|
||||
const nonce = await nextNonce(chain, alice.address);
|
||||
const send = (to, value) => ({
|
||||
Balances: { transfer_keep_alive: { dest: { Id: u8aToHex(to) }, value: value.toString() } }
|
||||
});
|
||||
// Five transfers in one batch_all, to push the signing payload over 256 bytes
|
||||
// so the BLAKE2b branch is the one that runs. A call nested inside a call also
|
||||
// exercises the codec at a depth a plain transfer never reaches.
|
||||
const call = runtime.encodeCall('Utility', 'batch_all', {
|
||||
calls: [
|
||||
send(fresh.addressRaw, FUNDING),
|
||||
send(alice.addressRaw, AMOUNT),
|
||||
send(alice.addressRaw, AMOUNT),
|
||||
send(alice.addressRaw, AMOUNT),
|
||||
send(alice.addressRaw, AMOUNT)
|
||||
]
|
||||
});
|
||||
const { hashed, hex, payload } = build(chain, alice, call, nonce, { signingContext: context });
|
||||
|
||||
report('a payload over 256 bytes is hashed before signing', hashed, `${payload.length} bytes -> blake2 32`);
|
||||
|
||||
try {
|
||||
const { height } = await submitAndInclude(chain, hex);
|
||||
// `batch_all` is atomic, so "included" and "did what it said" are different
|
||||
// claims. The balance is the one that matters.
|
||||
const funded = await freeBalance(chain, fresh.address);
|
||||
|
||||
report('a batch_all over 256 bytes dispatches', funded === FUNDING, `block ${height}, ${fresh.address} holds ${funded}`);
|
||||
} catch (error) {
|
||||
report('a batch_all over 256 bytes dispatches', false, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 3. an ML-DSA-65 account signs -----------------------------------------
|
||||
{
|
||||
const nonce = await nextNonce(chain, fresh.address);
|
||||
const { hex } = build(chain, fresh, transfer(chain, alice.address, AMOUNT), nonce, { signingContext: context });
|
||||
|
||||
try {
|
||||
const before = await freeBalance(chain, alice.address);
|
||||
const { height } = await submitAndInclude(chain, hex);
|
||||
const moved = await freeBalance(chain, alice.address) - before;
|
||||
|
||||
report('an ML-DSA-65 account signs and is accepted', moved === AMOUNT, `block ${height}, moved ${moved}`);
|
||||
} catch (error) {
|
||||
report('an ML-DSA-65 account signs and is accepted', false, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 4. exported to JSON, re-imported, still signs -------------------------
|
||||
{
|
||||
const password = 'tier1-round-trip';
|
||||
const json = fresh.toJson(password);
|
||||
const reimported = keyring.createFromJson(json);
|
||||
|
||||
reimported.decodePkcs8(password);
|
||||
|
||||
const sameAddress = reimported.address === fresh.address;
|
||||
const nonce = await nextNonce(chain, reimported.address);
|
||||
const { hex } = build(chain, reimported, transfer(chain, alice.address, AMOUNT), nonce, { signingContext: context });
|
||||
|
||||
try {
|
||||
const before = await freeBalance(chain, alice.address);
|
||||
const { height } = await submitAndInclude(chain, hex);
|
||||
const moved = await freeBalance(chain, alice.address) - before;
|
||||
|
||||
report('an account exported to JSON and re-imported still signs', sameAddress && moved === AMOUNT, `block ${height}, address preserved=${sameAddress}, moved ${moved}`);
|
||||
} catch (error) {
|
||||
report('an account exported to JSON and re-imported still signs', false, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
await chain.disconnect();
|
||||
|
||||
console.log('');
|
||||
console.log(failures ? `${failures} case(s) failed` : 'all cases passed');
|
||||
process.exit(failures ? 1 : 0);
|
||||
151
scripts/tier1/chain.mjs
Normal file
151
scripts/tier1/chain.mjs
Normal file
@@ -0,0 +1,151 @@
|
||||
// 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()
|
||||
});
|
||||
}
|
||||
@@ -4,28 +4,28 @@
|
||||
// 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
|
||||
// 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. `WsProvider` appears only as a
|
||||
// JSON-RPC transport; no type in this file is decoded by it.
|
||||
// `Metadata_metadata` against the runtime WASM.
|
||||
|
||||
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';
|
||||
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';
|
||||
// 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();
|
||||
|
||||
@@ -34,140 +34,65 @@ const pair = keyring.createFromUri(SEED, { name: 'crystal_alice' }, TYPE);
|
||||
|
||||
console.log(`signer ${pair.address}`);
|
||||
|
||||
const provider = new WsProvider(ENDPOINT);
|
||||
const chain = await connect(ENDPOINT);
|
||||
const { genesisHash, runtime, specVersion } = chain;
|
||||
|
||||
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(`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
|
||||
const amount = 1_000_000_000n; // 0.001
|
||||
const destId = u8aToHex(decodeAddress(dest));
|
||||
const nonce = parseInt(await provider.send('system_accountNextIndex', [pair.address]), 10);
|
||||
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 = runtime.encodeCall('Balances', 'transfer_keep_alive', {
|
||||
dest: { Id: destId },
|
||||
value: amount.toString()
|
||||
});
|
||||
const call = transfer(chain, dest, amount);
|
||||
|
||||
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`
|
||||
// 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) && !values[e.identifier]);
|
||||
.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 extra = runtime.encodeExtra(values);
|
||||
const payload = runtime.signerPayload(call, values);
|
||||
const toSign = payload.length > HASH_ABOVE ? blake2AsU8a(payload) : payload;
|
||||
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 ${toSign.length}`);
|
||||
console.log(`payload ${payload.length} bytes -> signing ${hashed ? '32 (blake2)' : payload.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`);
|
||||
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 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);
|
||||
// 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 hash = await provider.send('author_submitExtrinsic', [u8aToHex(extrinsic)]);
|
||||
const { blockHash, height, index } = await submitAndInclude(chain, hex);
|
||||
|
||||
// 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}`);
|
||||
console.log(`included block ${height} index ${index} (${blockHash})`);
|
||||
|
||||
// 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;
|
||||
const after = await freeBalance(chain, dest);
|
||||
|
||||
let height = parseInt((await provider.send('chain_getHeader', [])).number, 16);
|
||||
let found = null;
|
||||
console.log(`after ${after}`);
|
||||
console.log(`delta ${after - before} (expected ${amount})`);
|
||||
|
||||
while (!found && Date.now() < DEADLINE) {
|
||||
const head = parseInt((await provider.send('chain_getHeader', [])).number, 16);
|
||||
await chain.disconnect();
|
||||
|
||||
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 (after - before !== amount) {
|
||||
throw new Error(`recipient balance moved by ${after - before}, expected ${amount}`);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
12
yarn.lock
12
yarn.lock
@@ -982,7 +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.2.0"
|
||||
"@quantus/codec": "npm:^0.4.0"
|
||||
"@quantus/crypto": "npm:^0.1.1"
|
||||
eventemitter3: "npm:^5.0.1"
|
||||
rxjs: "npm:^7.8.1"
|
||||
@@ -998,7 +998,7 @@ __metadata:
|
||||
"@polkadot/networks": "npm:^14.0.3"
|
||||
"@polkadot/util": "npm:^14.0.3"
|
||||
"@polkadot/util-crypto": "npm:^14.0.3"
|
||||
"@quantus/codec": "npm:^0.2.0"
|
||||
"@quantus/codec": "npm:^0.4.0"
|
||||
tslib: "npm:^2.8.1"
|
||||
peerDependencies:
|
||||
"@polkadot/api": "*"
|
||||
@@ -1604,13 +1604,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@quantus/codec@npm:^0.2.0":
|
||||
version: 0.2.0
|
||||
resolution: "@quantus/codec@npm:0.2.0::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcodec%2F-%2F0.2.0%2Fcodec-0.2.0.tgz"
|
||||
"@quantus/codec@npm:^0.4.0":
|
||||
version: 0.4.0
|
||||
resolution: "@quantus/codec@npm:0.4.0::__archiveUrl=https%3A%2F%2Fgit.lair.cafe%2Fapi%2Fpackages%2Fquantus%2Fnpm%2F%2540quantus%252Fcodec%2F-%2F0.4.0%2Fcodec-0.4.0.tgz"
|
||||
dependencies:
|
||||
fflate: "npm:^0.8.2"
|
||||
tslib: "npm:^2.7.0"
|
||||
checksum: 10/ef03f65170153ac3f389677e1793dee65803ca8fc03e3e48e3c47885461f79b8bd020052b2fff500437de4b96afaa200045ef88c26f3647f1c04496fc2c22823
|
||||
checksum: 10/9327551cf97a5bf0a54d5cf1ef9703efc94e85138f24346d597fe4860570d451a3301db0dc1f7a382bc760491ff3ea823cecf384d07e0559f766bafb9ec19b46
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user