feat: add the tier 2 harness dapp
quantus/extension#7 tier 2: a page that asks the *extension* to sign, then
assembles and submits the extrinsic itself. `yarn tier2` bundles it; the README
has the run steps and where to get a funded dev account.
Every intermediate value is printed — the call, the extra, the payload that was
signed, the signature that came back, the assembled extrinsic, and the round trip
decoded back out of it. That display is the debugger when a node rejects
something, and it is exactly what a real app hides behind a success toast.
Nothing in it uses @polkadot/api. @quantus/codec produces every byte and the
WebSocket is a JSON-RPC client that decodes nothing.
Also extracts decodeMethod out of Extrinsic.tsx into util/, with tests. It moved
from `registry.createType('Call', data)` to the runtime in the previous commit and
was the one changed path with no coverage — `yarn test` skips .spec.tsx, so as a
component method it would have stayed that way. The tests pin the three cases
that matter more than the happy path: metadata from a different spec version
decodes a call into something plausible and wrong and must be refused; a chain
with no runtime renders hex; and bytes that are not a call must not throw, since
an exception there lands between somebody and their funds.
Verified so far in a browser: the page loads under Firefox with the extension
installed, and @quantus/codec parses Heisenberg's 101,493-byte metadata client
side and reports extrinsic v4 with all twelve signed extensions. The injected
signing round trip is **not** yet verified end to end — driving the extension's
own UI needs a privileged browsing context this tooling cannot script — so the
popup half of tier 2 still wants a human at a keyboard. The background half is
covered by Extension.spec.ts, which runs the full pub(extrinsic.sign) ->
pri(signing.approve.password) path.
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:
+2
-1
@@ -42,7 +42,8 @@
|
||||
"lint": "polkadot-dev-run-lint",
|
||||
"postinstall": "polkadot-dev-yarn-only",
|
||||
"test": "EXTENSION_PREFIX='test' polkadot-dev-run-test --loader ./packages/extension-mocks/src/loader-empty.js --env browser ^:.spec.tsx",
|
||||
"test:one": "EXTENSION_PREFIX='test' polkadot-dev-run-test --env browser"
|
||||
"test:one": "EXTENSION_PREFIX='test' polkadot-dev-run-test --env browser",
|
||||
"tier2": "polkadot-exec-webpack --config scripts/tier2/webpack.config.cjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@polkadot/dev": "^0.83.3",
|
||||
|
||||
@@ -30,7 +30,7 @@ const raw = readFileSync(
|
||||
'utf-8'
|
||||
).trim();
|
||||
|
||||
export const heisenbergRawMetadata = `0x${raw}` as HexString;
|
||||
export const heisenbergRawMetadata: HexString = `0x${raw}`;
|
||||
|
||||
export function heisenbergRuntime (): Runtime {
|
||||
return Runtime.fromMetadata(hexToU8a(heisenbergRawMetadata));
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Chain } from '@polkadot/extension-chains/types';
|
||||
import type { ExtrinsicEra, ExtrinsicPayload } from '@polkadot/types/interfaces';
|
||||
import type { SignerPayloadJSON } from '@polkadot/types/types';
|
||||
import type { BN } from '@polkadot/util';
|
||||
import type { TFunction } from '../../hooks/useTranslation.js';
|
||||
import type { Decoded } from '../../util/decodeMethod.js';
|
||||
|
||||
import { convertMultilocationToUrl } from '@paraspell/xcm-analyser';
|
||||
import React, { useMemo, useRef } from 'react';
|
||||
|
||||
import { bnToBn, formatNumber, hexToU8a } from '@polkadot/util';
|
||||
import { bnToBn, formatNumber } from '@polkadot/util';
|
||||
|
||||
import { Table } from '../../components/index.js';
|
||||
import { useMetadata, useTranslation } from '../../hooks/index.js';
|
||||
|
||||
interface Decoded {
|
||||
/** The call's arguments, by the names the runtime gives them. */
|
||||
args: Record<string, unknown> | null;
|
||||
/** `pallet.call`, as the runtime names them. */
|
||||
name: string | null;
|
||||
}
|
||||
import { decodeMethod } from '../../util/decodeMethod.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@@ -29,49 +22,6 @@ interface Props {
|
||||
url: string;
|
||||
}
|
||||
|
||||
function displayDecodeVersion (message: string, chain: Chain, specVersion: BN): string {
|
||||
return `${message}: chain=${chain.name}, specVersion=${chain.specVersion.toString()} (request specVersion=${specVersion.toString()})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the call the user is being asked to approve, using the runtime's own
|
||||
* description of itself.
|
||||
*
|
||||
* Not `registry.createType('Call', data)`. `@polkadot/types` cannot hold this
|
||||
* chain's metadata — see `metadataExpand` — so this screen would have nothing to
|
||||
* show and would fall back to raw hex for every transaction, which is the
|
||||
* failure mode where a user approves bytes nobody read to them.
|
||||
*
|
||||
* The spec-version check stays: metadata from a different runtime decodes a call
|
||||
* into something plausible and wrong, and showing that is worse than showing
|
||||
* hex.
|
||||
*/
|
||||
function decodeMethod (data: string, chain: Chain, specVersion: BN): Decoded {
|
||||
try {
|
||||
if (!specVersion.eqn(chain.specVersion)) {
|
||||
console.log(displayDecodeVersion('Outdated metadata to decode', chain, specVersion));
|
||||
|
||||
return { args: null, name: null };
|
||||
}
|
||||
|
||||
if (!chain.runtime) {
|
||||
return { args: null, name: null };
|
||||
}
|
||||
|
||||
// `{ Pallet: { call_name: { arg: value } } }` — two nested variants, named
|
||||
// by the runtime.
|
||||
const decoded = chain.runtime.decodeCall(hexToU8a(data)) as Record<string, Record<string, Record<string, unknown>>>;
|
||||
const pallet = Object.keys(decoded)[0];
|
||||
const call = Object.keys(decoded[pallet])[0];
|
||||
|
||||
return { args: decoded[pallet][call], name: `${pallet}.${call}` };
|
||||
} catch (error) {
|
||||
console.error(`${displayDecodeVersion('Error decoding method', chain, specVersion)}:: ${(error as Error).message}`);
|
||||
|
||||
return { args: null, name: null };
|
||||
}
|
||||
}
|
||||
|
||||
function renderMethod (data: string, { args, name }: Decoded, t: TFunction): React.ReactNode {
|
||||
if (!args || !name) {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
/// <reference types="@polkadot/dev-test/globals.d.ts" />
|
||||
|
||||
import type { Chain } from '@polkadot/extension-chains/types';
|
||||
|
||||
import { HEISENBERG_SPEC, heisenbergMetadataDef } from '@polkadot/extension-base/test/metadata';
|
||||
import { metadataExpand } from '@polkadot/extension-chains';
|
||||
import { bnToBn, u8aToHex } from '@polkadot/util';
|
||||
|
||||
import { decodeMethod } from './decodeMethod.js';
|
||||
|
||||
describe('decoding a call for the approval screen', (): void => {
|
||||
const chain: Chain = metadataExpand(heisenbergMetadataDef(), false);
|
||||
const runtime = chain.runtime;
|
||||
|
||||
if (!runtime) {
|
||||
throw new Error('the fixture metadata did not produce a runtime');
|
||||
}
|
||||
|
||||
const transfer = u8aToHex(runtime.encodeCall('Balances', 'transfer_keep_alive', {
|
||||
dest: { Id: `0x${'11'.repeat(32)}` },
|
||||
value: '1000000000'
|
||||
}));
|
||||
|
||||
// What the screen exists to do. Before this it went through
|
||||
// registry.createType('Call', …), which cannot work on a chain @polkadot/types
|
||||
// cannot describe — so every transaction rendered as raw hex, which is the
|
||||
// failure mode where somebody approves bytes nobody read to them.
|
||||
it('names the call and its arguments as the runtime does', (): void => {
|
||||
const { args, name } = decodeMethod(transfer, chain, bnToBn(HEISENBERG_SPEC));
|
||||
|
||||
expect(name).toEqual('Balances.transfer_keep_alive');
|
||||
expect(args?.['value']).toEqual('1000000000');
|
||||
expect(args?.['dest']).toEqual({ Id: `0x${'11'.repeat(32)}` });
|
||||
});
|
||||
|
||||
// Metadata from a different runtime decodes a call into something plausible and
|
||||
// wrong. Showing hex is worse than showing a decoded call; showing the *wrong*
|
||||
// decoded call is worse than both.
|
||||
it('refuses to decode against a different spec version', (): void => {
|
||||
expect(decodeMethod(transfer, chain, bnToBn(HEISENBERG_SPEC + 1))).toEqual({ args: null, name: null });
|
||||
});
|
||||
|
||||
// A chain the extension holds no metadata for. It will not sign one of these
|
||||
// either — see RequestExtrinsicSign — but the screen still has to render, and
|
||||
// rendering hex is the honest thing to show.
|
||||
it('returns nothing readable when there is no runtime', (): void => {
|
||||
expect(decodeMethod(transfer, { ...chain, runtime: null }, bnToBn(HEISENBERG_SPEC))).toEqual({ args: null, name: null });
|
||||
});
|
||||
|
||||
// Bytes that are not a call at all. The screen must not throw: an exception
|
||||
// here lands between somebody and their funds.
|
||||
it('returns nothing readable for bytes that are not a call', (): void => {
|
||||
expect(decodeMethod('0xdeadbeef', chain, bnToBn(HEISENBERG_SPEC))).toEqual({ args: null, name: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { Chain } from '@polkadot/extension-chains/types';
|
||||
import type { BN } from '@polkadot/util';
|
||||
|
||||
import { hexToU8a } from '@polkadot/util';
|
||||
|
||||
export interface Decoded {
|
||||
/** The call's arguments, by the names the runtime gives them. */
|
||||
args: Record<string, unknown> | null;
|
||||
/** `pallet.call`, as the runtime names them. */
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
const UNDECODED: Decoded = { args: null, name: null };
|
||||
|
||||
export function displayDecodeVersion (message: string, chain: Chain, specVersion: BN): string {
|
||||
return `${message}: chain=${chain.name}, specVersion=${chain.specVersion.toString()} (request specVersion=${specVersion.toString()})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode the call a user is being asked to approve, using the runtime's own
|
||||
* description of itself.
|
||||
*
|
||||
* Not `registry.createType('Call', data)`. `@polkadot/types` cannot hold this
|
||||
* chain's metadata — it caps fixed arrays at 2048 bytes and the ML-DSA signature
|
||||
* types are 5261 and 7219 — so the approval screen would fall back to raw hex for
|
||||
* every transaction, which is the failure mode where somebody approves bytes
|
||||
* nobody read to them. See quantus/api#1.
|
||||
*
|
||||
* The spec-version check is kept from upstream and matters more here than it
|
||||
* looks: metadata from a different runtime decodes a call into something
|
||||
* plausible and wrong, and showing that is worse than showing hex.
|
||||
*
|
||||
* Returns nulls rather than throwing. A call this cannot read is one the caller
|
||||
* should render as hex, not an exception on the screen standing between someone
|
||||
* and their funds.
|
||||
*/
|
||||
export function decodeMethod (data: string, chain: Chain, specVersion: BN): Decoded {
|
||||
try {
|
||||
if (!specVersion.eqn(chain.specVersion)) {
|
||||
console.log(displayDecodeVersion('Outdated metadata to decode', chain, specVersion));
|
||||
|
||||
return UNDECODED;
|
||||
}
|
||||
|
||||
if (!chain.runtime) {
|
||||
return UNDECODED;
|
||||
}
|
||||
|
||||
// `{ Pallet: { call_name: { arg: value } } }` — two nested variants, named by
|
||||
// the runtime rather than by anything in this repo.
|
||||
const decoded = chain.runtime.decodeCall(hexToU8a(data)) as Record<string, Record<string, Record<string, unknown>>>;
|
||||
const pallet = Object.keys(decoded)[0];
|
||||
const call = Object.keys(decoded[pallet])[0];
|
||||
|
||||
return { args: decoded[pallet][call], name: `${pallet}.${call}` };
|
||||
} catch (error) {
|
||||
console.error(`${displayDecodeVersion('Error decoding method', chain, specVersion)}:: ${(error as Error).message}`);
|
||||
|
||||
return UNDECODED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# Tier 2 harness
|
||||
|
||||
[quantus/extension#7](https://git.lair.cafe/quantus/extension/issues/7) tier 2: a
|
||||
dapp that asks the **extension** to sign, then assembles and submits the extrinsic
|
||||
itself.
|
||||
|
||||
The point is not the UI. Every intermediate value is printed — the call, the
|
||||
`extra`, the payload that was signed, the signature that came back, the assembled
|
||||
extrinsic, and the round trip decoded from it. When a node rejects something, that
|
||||
display is the debugger, and it is exactly what a real app hides behind a success
|
||||
toast.
|
||||
|
||||
Nothing here uses `@polkadot/api`. `@quantus/codec` produces every byte, from
|
||||
metadata the node generated by running `Metadata_metadata` against the runtime
|
||||
WASM; the WebSocket is a JSON-RPC transport and decodes nothing. See
|
||||
[quantus/api#1](https://git.lair.cafe/quantus/api/issues/1) for why.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
yarn build # the extension, into packages/extension/build
|
||||
yarn tier2 # bundles scripts/tier2/src/harness.js
|
||||
python3 -m http.server 8731
|
||||
```
|
||||
|
||||
Then load `packages/extension/build` as an unpacked extension —
|
||||
`about:debugging#/runtime/this-firefox` → *Load Temporary Add-on*, or
|
||||
`chrome://extensions` with developer mode on → *Load unpacked* — and open
|
||||
<http://localhost:8731/scripts/tier2/index.html>.
|
||||
|
||||
It has to be served over HTTP. The extension does not inject into `file://` pages,
|
||||
so `web3Enable` returns nothing there.
|
||||
|
||||
## A funded account
|
||||
|
||||
The dev genesis endows three accounts from trivial seeds
|
||||
(`primitives/dilithium-crypto/src/pair.rs` in the chain):
|
||||
|
||||
| account | seed | scheme |
|
||||
| --- | --- | --- |
|
||||
| `crystal_alice` | 32 × `0x00` | ML-DSA-87 |
|
||||
| `dilithium_bob` | 32 × `0x01` | ML-DSA-87 |
|
||||
| `crystal_charlie` | 32 × `0x02` | ML-DSA-87 |
|
||||
|
||||
Import one with *Import account from pre-existing seed* and the raw hex seed
|
||||
`0x0000…00`. On Heisenberg `crystal_alice` is
|
||||
`qzk1Nxai3dZD9Cn5kwGcgL6mKxsfxwqdis7kDQJ52aJS2vSn7`.
|
||||
|
||||
Two things that makes it worth using early: these are **ML-DSA-87**, the legacy
|
||||
scheme, where new accounts are ML-DSA-65; and it is the **non-HD** path, a raw
|
||||
32-byte seed straight into keygen with no derivation, which is a different code
|
||||
path from the mnemonic one.
|
||||
|
||||
## What the buttons do
|
||||
|
||||
- **connect** — fetches the runtime version, genesis hash and metadata, and lists
|
||||
the signed extensions the runtime declares. Two of them,
|
||||
`ReversibleTransactionExtension` and `WormholeProofRecorderExtension`, exist on
|
||||
no other chain.
|
||||
- **list accounts** — `web3Enable` then `web3Accounts`. The first call raises an
|
||||
authorization request in the extension.
|
||||
- **signRaw** — the bytes channel. Signs under the **empty** FIPS 204 context, so
|
||||
the result can never be replayed as an extrinsic.
|
||||
- **build, sign and submit** — provides this chain's metadata to the extension,
|
||||
builds the call, asks the extension to sign, assembles the extrinsic and
|
||||
submits it.
|
||||
|
||||
That metadata step is not optional. The extension **refuses** to sign an
|
||||
extrinsic for a chain it has no metadata for, because without the runtime's own
|
||||
description of its signed extensions it would be guessing at the payload — and a
|
||||
signature over the wrong bytes comes back from a node as `BadProof`, which is
|
||||
also what it says about a wrong key.
|
||||
|
||||
## Tier 1
|
||||
|
||||
`../tier1/submit.mjs` does the same thing with no browser and no extension,
|
||||
signing with the forked keyring directly. It is the faster loop, and the one that
|
||||
answers "is this a crypto bug or a plumbing bug".
|
||||
|
||||
```sh
|
||||
node scripts/tier1/submit.mjs
|
||||
QUANTUS_WS=wss://… QUANTUS_DEST=qzk… node scripts/tier1/submit.mjs
|
||||
```
|
||||
@@ -0,0 +1,61 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Quantus extension — tier 2 harness</title>
|
||||
<style>
|
||||
body { font: 13px/1.5 ui-monospace, monospace; margin: 0; padding: 1rem 1.25rem; }
|
||||
h1 { font-size: 1rem; margin: 0 0 0.25rem; }
|
||||
p.note { color: #666; margin: 0 0 1rem; max-width: 60rem; }
|
||||
fieldset { border: 1px solid #ccc; margin: 0 0 0.75rem; padding: 0.5rem 0.75rem; }
|
||||
legend { color: #666; padding: 0 0.25rem; }
|
||||
label { display: inline-block; min-width: 5rem; }
|
||||
input, select { font: inherit; margin: 0.15rem 0; padding: 0.15rem 0.3rem; width: 32rem; max-width: 100%; }
|
||||
button { font: inherit; margin: 0.25rem 0.25rem 0.25rem 0; padding: 0.2rem 0.6rem; }
|
||||
#log { border-top: 1px solid #ccc; margin-top: 1rem; padding-top: 0.5rem; }
|
||||
.row { display: flex; gap: 0.75rem; padding: 0.1rem 0; }
|
||||
.label { color: #666; flex: 0 0 9rem; text-align: right; }
|
||||
.value { flex: 1; overflow-wrap: anywhere; }
|
||||
.good .value { color: #0a0; }
|
||||
.bad .value { color: #c00; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Quantus extension — tier 2 harness</h1>
|
||||
<p class="note">
|
||||
quantus/extension#7 tier 2. Every intermediate value is printed: the call, the extra,
|
||||
the payload that was signed, the signature that came back, and the assembled extrinsic.
|
||||
The extension signs; this page assembles and submits. Nothing here uses
|
||||
<code>@polkadot/api</code> — the WebSocket is a JSON-RPC transport and
|
||||
<code>@quantus/codec</code> does every byte.
|
||||
</p>
|
||||
|
||||
<fieldset>
|
||||
<legend>chain</legend>
|
||||
<label for="endpoint">endpoint</label><input id="endpoint" type="text">
|
||||
<div><button id="connect" type="button">connect</button></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>accounts</legend>
|
||||
<label for="account">account</label><select id="account"></select>
|
||||
<div><button id="accounts" type="button">list accounts</button></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>sign raw bytes</legend>
|
||||
<label for="message">message</label><input id="message" type="text" value="hello from tier 2">
|
||||
<div><button id="sign-raw" type="button">signRaw</button></div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>transfer</legend>
|
||||
<label for="dest">to</label><input id="dest" type="text" value="qzkYEQv8tQsmniZYdame3Cku18RL5g9bGK9Pdydq5TMPdpE3y">
|
||||
<label for="amount">amount</label><input id="amount" type="text" value="1000000000">
|
||||
<div><button id="transfer" type="button">build, sign and submit</button></div>
|
||||
</fieldset>
|
||||
|
||||
<div id="log"></div>
|
||||
<script src="build/harness.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,264 @@
|
||||
// Copyright 2019-2026 @polkadot/extension authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// quantus/extension#7 tier 2: a dapp that asks the *extension* to sign, then
|
||||
// assembles and submits the extrinsic itself.
|
||||
//
|
||||
// The point is not the UI. It is that every intermediate value is on screen —
|
||||
// the call, the extra, the payload that was signed, the signature that came
|
||||
// back, the assembled extrinsic. When a node rejects something, that display is
|
||||
// the debugger, and it is exactly what a real app hides behind a success toast.
|
||||
//
|
||||
// Nothing here uses @polkadot/api. The call and the extrinsic are built by
|
||||
// @quantus/codec from metadata the node produced by running Metadata_metadata
|
||||
// against the runtime WASM; the WebSocket below is a JSON-RPC transport and
|
||||
// decodes nothing. See quantus/api#1.
|
||||
|
||||
import { Runtime } from '@quantus/codec';
|
||||
|
||||
import { web3Accounts, web3Enable, web3FromSource } from '@polkadot/extension-dapp';
|
||||
import { hexToU8a, u8aToHex } from '@polkadot/util';
|
||||
import { decodeAddress } from '@polkadot/util-crypto';
|
||||
|
||||
const DEFAULT_WS = 'wss://a1-heisenberg.quantus.cat';
|
||||
|
||||
let rpcId = 0;
|
||||
let socket = null;
|
||||
let runtime = null;
|
||||
let chain = null;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function show (label, value) {
|
||||
const row = document.createElement('div');
|
||||
|
||||
row.className = 'row';
|
||||
row.innerHTML = '<span class="label"></span><span class="value"></span>';
|
||||
row.querySelector('.label').textContent = label;
|
||||
row.querySelector('.value').textContent = value;
|
||||
|
||||
$('log').appendChild(row);
|
||||
row.scrollIntoView({ block: 'nearest' });
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function fail (error) {
|
||||
show('error', error.message || String(error)).classList.add('bad');
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
/** A JSON-RPC client, and nothing more. No types cross this boundary decoded. */
|
||||
function rpc (method, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++rpcId;
|
||||
|
||||
const onMessage = ({ data }) => {
|
||||
const msg = JSON.parse(data);
|
||||
|
||||
if (msg.id !== id) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.removeEventListener('message', onMessage);
|
||||
msg.error ? reject(new Error(`${msg.error.code}: ${msg.error.message}`)) : resolve(msg.result);
|
||||
};
|
||||
|
||||
socket.addEventListener('message', onMessage);
|
||||
socket.send(JSON.stringify({ id, jsonrpc: '2.0', method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
async function connect () {
|
||||
const endpoint = $('endpoint').value.trim() || DEFAULT_WS;
|
||||
|
||||
socket = new WebSocket(endpoint);
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
socket.addEventListener('open', resolve, { once: true });
|
||||
socket.addEventListener('error', () => reject(new Error(`cannot reach ${endpoint}`)), { once: true });
|
||||
});
|
||||
|
||||
const [version, genesisHash, metadata, properties] = await Promise.all([
|
||||
rpc('state_getRuntimeVersion'),
|
||||
rpc('chain_getBlockHash', ['0x0']),
|
||||
rpc('state_getMetadata'),
|
||||
rpc('system_properties')
|
||||
]);
|
||||
|
||||
runtime = Runtime.fromMetadata(hexToU8a(metadata));
|
||||
chain = {
|
||||
genesisHash,
|
||||
rawMetadata: metadata,
|
||||
specName: version.specName,
|
||||
specVersion: version.specVersion,
|
||||
ss58Format: properties.ss58Format ?? 189,
|
||||
tokenDecimals: Array.isArray(properties.tokenDecimals) ? properties.tokenDecimals[0] : properties.tokenDecimals,
|
||||
tokenSymbol: Array.isArray(properties.tokenSymbol) ? properties.tokenSymbol[0] : properties.tokenSymbol,
|
||||
transactionVersion: version.transactionVersion
|
||||
};
|
||||
|
||||
show('chain', `${chain.specName} spec ${chain.specVersion} tx ${chain.transactionVersion}`);
|
||||
show('genesis', genesisHash);
|
||||
show('metadata', `${(metadata.length - 2) / 2} bytes, extrinsic v${runtime.extrinsicVersion}`);
|
||||
show('extensions', runtime.signedExtensions().map((e) => e.identifier).join(', '));
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the extension this chain's metadata.
|
||||
*
|
||||
* Not optional any more. The extension refuses to sign an extrinsic for a chain
|
||||
* it has no metadata for, because without the runtime's own description of its
|
||||
* signed extensions it would be guessing at the payload — and a signature over
|
||||
* the wrong bytes comes back from a node as `BadProof`, which is also what it
|
||||
* says about a wrong key.
|
||||
*/
|
||||
async function provideMetadata (source) {
|
||||
const injected = await web3FromSource(source);
|
||||
|
||||
const accepted = await injected.metadata.provide({
|
||||
chain: chain.specName,
|
||||
genesisHash: chain.genesisHash,
|
||||
icon: 'substrate',
|
||||
rawMetadata: chain.rawMetadata,
|
||||
specVersion: chain.specVersion,
|
||||
ss58Format: chain.ss58Format,
|
||||
tokenDecimals: chain.tokenDecimals,
|
||||
tokenSymbol: chain.tokenSymbol,
|
||||
types: {}
|
||||
});
|
||||
|
||||
show('metadata provided', accepted);
|
||||
|
||||
return injected;
|
||||
}
|
||||
|
||||
async function listAccounts () {
|
||||
const extensions = await web3Enable('quantus tier 2');
|
||||
|
||||
show('extensions', extensions.map((e) => `${e.name} ${e.version}`).join(', ') || 'none');
|
||||
|
||||
if (!extensions.length) {
|
||||
throw new Error('no extension responded to web3Enable — is it installed and is this page served over http?');
|
||||
}
|
||||
|
||||
const accounts = await web3Accounts();
|
||||
|
||||
$('account').innerHTML = '';
|
||||
|
||||
for (const { address, meta } of accounts) {
|
||||
const option = document.createElement('option');
|
||||
|
||||
option.value = address;
|
||||
option.dataset.source = meta.source;
|
||||
option.textContent = `${meta.name || 'unnamed'} — ${address}`;
|
||||
$('account').appendChild(option);
|
||||
}
|
||||
|
||||
show('accounts', accounts.length);
|
||||
}
|
||||
|
||||
function selected () {
|
||||
const option = $('account').selectedOptions[0];
|
||||
|
||||
if (!option) {
|
||||
throw new Error('no account selected — click "list accounts" first');
|
||||
}
|
||||
|
||||
return { address: option.value, source: option.dataset.source };
|
||||
}
|
||||
|
||||
async function signRaw () {
|
||||
const { address, source } = selected();
|
||||
const injected = await web3FromSource(source);
|
||||
const data = u8aToHex(new TextEncoder().encode($('message').value));
|
||||
|
||||
show('signing bytes', data);
|
||||
|
||||
const { signature } = await injected.signer.signRaw({ address, data, type: 'bytes' });
|
||||
|
||||
show('signature', `${(signature.length - 2) / 2} bytes`);
|
||||
show('signature hex', signature);
|
||||
}
|
||||
|
||||
async function transfer () {
|
||||
const { address, source } = selected();
|
||||
const injected = await provideMetadata(source);
|
||||
const dest = $('dest').value.trim();
|
||||
const amount = $('amount').value.trim();
|
||||
|
||||
const call = runtime.encodeCall('Balances', 'transfer_keep_alive', {
|
||||
dest: { Id: u8aToHex(decodeAddress(dest)) },
|
||||
value: amount
|
||||
});
|
||||
|
||||
show('call', u8aToHex(call));
|
||||
|
||||
const nonce = parseInt(await rpc('system_accountNextIndex', [address]), 10);
|
||||
|
||||
show('nonce', nonce);
|
||||
|
||||
// An immortal era — quantus/extension#11 covers making these mortal. Its
|
||||
// implicit is the genesis hash, so there is no birth block for the signer and
|
||||
// the node to disagree about.
|
||||
const payload = {
|
||||
address,
|
||||
blockHash: chain.genesisHash,
|
||||
blockNumber: '0x00000000',
|
||||
era: '0x00',
|
||||
genesisHash: chain.genesisHash,
|
||||
method: u8aToHex(call),
|
||||
nonce: `0x${nonce.toString(16).padStart(8, '0')}`,
|
||||
signedExtensions: runtime.signedExtensions().map((e) => e.identifier),
|
||||
specVersion: `0x${chain.specVersion.toString(16).padStart(8, '0')}`,
|
||||
tip: `0x${'0'.repeat(32)}`,
|
||||
transactionVersion: `0x${chain.transactionVersion.toString(16).padStart(8, '0')}`,
|
||||
version: 4
|
||||
};
|
||||
|
||||
// Built here as well as in the extension, from the same metadata. The two must
|
||||
// agree: the extension signs its own copy, and this one is what gets assembled
|
||||
// around the signature. Showing it is the whole point of the tier.
|
||||
const values = runtime.standardExtensions({
|
||||
blockHash: payload.blockHash,
|
||||
eraHex: payload.era,
|
||||
genesisHash: payload.genesisHash,
|
||||
nonce,
|
||||
specVersion: chain.specVersion,
|
||||
transactionVersion: chain.transactionVersion
|
||||
});
|
||||
const extra = runtime.encodeExtra(values);
|
||||
|
||||
show('extra', u8aToHex(extra));
|
||||
show('payload', u8aToHex(runtime.signerPayload(call, values)));
|
||||
|
||||
const { signature } = await injected.signer.signPayload(payload);
|
||||
|
||||
show('signature', `${(signature.length - 2) / 2} bytes, variant 0x${signature.slice(2, 4)}`);
|
||||
show('signature hex', `${signature.slice(0, 66)}…`);
|
||||
|
||||
const extrinsic = runtime.encodeExtrinsic({ Id: u8aToHex(decodeAddress(address)) }, hexToU8a(signature), extra, call);
|
||||
|
||||
show('extrinsic', `${extrinsic.length} bytes`);
|
||||
show('extrinsic hex', `${u8aToHex(extrinsic).slice(0, 130)}…`);
|
||||
|
||||
// Decoded with the same runtime that built it, before the node sees it: a bug
|
||||
// here is ours, a rejection after this is a disagreement with the chain, and
|
||||
// telling those apart is most of the work.
|
||||
show('round trip', JSON.stringify(runtime.decodeExtrinsic(extrinsic).call));
|
||||
|
||||
const hash = await rpc('author_submitExtrinsic', [u8aToHex(extrinsic)]);
|
||||
|
||||
show('accepted', hash).classList.add('good');
|
||||
}
|
||||
|
||||
const wire = (id, fn) => $(id).addEventListener('click', () => {
|
||||
fn().catch(fail);
|
||||
});
|
||||
|
||||
wire('connect', connect);
|
||||
wire('accounts', listAccounts);
|
||||
wire('sign-raw', signRaw);
|
||||
wire('transfer', transfer);
|
||||
|
||||
$('endpoint').value = DEFAULT_WS;
|
||||
@@ -0,0 +1,37 @@
|
||||
// Copyright 2019-2026 @polkadot/extension authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Bundles the tier-2 harness. Separate from the extension's own webpack config:
|
||||
// this is a page served to a browser alongside the extension, not part of it.
|
||||
|
||||
const path = require('node:path');
|
||||
|
||||
module.exports = {
|
||||
entry: path.join(__dirname, 'src/harness.js'),
|
||||
experiments: {
|
||||
topLevelAwait: true
|
||||
},
|
||||
mode: 'development',
|
||||
output: {
|
||||
filename: 'harness.js',
|
||||
path: path.join(__dirname, 'build')
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
// The workspace package resolves to its source directory, whose `main`
|
||||
// points at build output that only exists after `yarn build`. Point at it
|
||||
// directly so the harness builds from a plain checkout.
|
||||
'@polkadot/extension-dapp$': path.join(__dirname, '../../packages/extension-dapp/build/bundle.js')
|
||||
},
|
||||
extensionAlias: {
|
||||
'.js': ['.js', '.ts']
|
||||
},
|
||||
// @quantus/codec inlines its WASM as base64 and never fetches it, but the
|
||||
// generated glue still mentions the file — see strip-wasm-fetch-init.mjs in
|
||||
// quantus/wasm. Nothing here should try to resolve it.
|
||||
fallback: {
|
||||
crypto: false
|
||||
}
|
||||
},
|
||||
target: 'web'
|
||||
};
|
||||
@@ -11,9 +11,12 @@
|
||||
"eslint.config.js",
|
||||
"i18next-scanner.config.cjs",
|
||||
"rollup.config.js",
|
||||
"scripts/**/*.mjs"
|
||||
"scripts/**/*.mjs",
|
||||
"scripts/**/*.js",
|
||||
"scripts/**/*.cjs"
|
||||
],
|
||||
"exclude": [
|
||||
"**/node_modules/**/*"
|
||||
"**/node_modules/**/*",
|
||||
"scripts/tier2/build/**/*"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user