From bfd3aa80bafa3b4efc5124e7111b55624d586b47 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 16 Sep 2026 11:05:24 +0300 Subject: [PATCH] feat: fetch a chain's metadata from a node, not from the dapp asking for a signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Substrate chains describe themselves. Almost nothing that talks to them takes advantage of it: polkadot-js hardcodes `ExtrinsicSignature: 'MultiSignature'` in a definitions file and caps fixed arrays at 2048 bytes, polkadot-api reads the signature type out of metadata and then discards the answer for a three-name whitelist, and its block header is a static struct. Each is a client deciding in advance what a chain is allowed to be. This extension already reads calls and signed extensions from the runtime; this is the last piece that did not — where the runtime's description came from. It came from the dapp. Two problems, and the second is the serious one. **It does not always happen.** Not every dapp calls `metadata.provide` — the qapi console does not, because `@polkadot-api/pjs-signer` narrows the injected interface to `{ name, getAccounts, subscribe, disconnect }` and never surfaces that half. Since this extension refuses to sign for a chain it cannot describe, such a dapp could not get a signature at all. **And the dapp is not a trustworthy source.** Metadata decides what the approval screen *says* a call does. A dapp supplying its own controls both the transaction and its description, so it can render something harmless and have the user sign a transfer. Asking the chain means the description and the thing described come from the same place, and it is not the page asking for the signature. So `ChainMetadata` fetches it, and `pub(extrinsic.sign)` awaits that before queuing — before the popup, not after approval, because a user asked to approve a transaction rendered as raw hex has been given nothing to approve. Three things that make this safe rather than merely convenient: - **Endpoints are believed only if they prove their identity.** A sign request names a genesisHash; the endpoint's `chain_getBlockHash(0)` must equal it. Without that the table of hosts would itself be the thing taken on trust, which is not an improvement over trusting the dapp. Verified by deliberately pointing Planck's genesis at Heisenberg's nodes: both were rejected — "serves 0xa5aa9e5c…, not 0x4901bf5c…" — and the fetch returned null. - **The payload's spec version is the cache key, not mere presence.** A runtime upgrade changes which calls and signed extensions exist, and metadata from before one decodes this chain's calls into something plausible and wrong. It also keeps the common case off the network entirely. Mainnet is on spec 152 today where the testnets are on 148, so this is not hypothetical. - **A chain with no endpoint returns null instantly**, with no connection attempted, and the signer's existing refusal explains it. Verified against the live chains rather than by unit test: Quantus spec 152 QTC, Heisenberg 148 HEI, Planck 148 PLK, each 101 KB of metadata with ss58 189; unknown chain null in 0 ms; second call served from cache in 0 ms. `extrinsicSign` is now async before it queues, so the specs poll for the request instead of assuming it is already there — it was a race they had been winning by accident. 92 tests pass, lint clean, builds. Refs #1, quantus/papi-console#3 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f --- .../src/background/ChainMetadata.ts | 144 ++++++++++++++++++ .../src/background/handlers/Extension.spec.ts | 22 ++- .../src/background/handlers/State.ts | 40 +++++ .../src/background/handlers/Tabs.ts | 13 +- packages/extension-base/src/defaults.ts | 29 ++++ 5 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 packages/extension-base/src/background/ChainMetadata.ts diff --git a/packages/extension-base/src/background/ChainMetadata.ts b/packages/extension-base/src/background/ChainMetadata.ts new file mode 100644 index 00000000..5eb7c526 --- /dev/null +++ b/packages/extension-base/src/background/ChainMetadata.ts @@ -0,0 +1,144 @@ +// Copyright 2019-2026 @polkadot/extension-base authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { MetadataDef } from '@polkadot/extension-inject/types'; +import type { HexString } from '@polkadot/util/types'; + +import { QUANTUS_CHAINS } from '@polkadot/extension-base/defaults'; +import { WsProvider } from '@polkadot/rpc-provider'; + +interface RuntimeVersion { + specName: string; + specVersion: number; + transactionVersion: number; +} + +/** + * Metadata, fetched from a node rather than accepted from a dapp. + * + * ## Why this exists + * + * Upstream's extension learns what a chain looks like only when a dapp tells it + * (`metadata.provide`). That has two problems, and the second is the serious one. + * + * **It does not always happen.** Not every dapp provides metadata — the qapi + * console does not, because `@polkadot-api/pjs-signer` never exposes that half + * of the injected interface. Since this extension refuses to sign an extrinsic + * for a chain it cannot describe, a dapp that does not provide metadata cannot + * get a signature at all. + * + * **And the dapp is not a trustworthy source for it.** Metadata decides what the + * approval screen *says* a call does. A dapp that supplies its own controls both + * the transaction and its description, so it can show a harmless-looking call and + * have the user sign a transfer. Asking the chain removes that: the description + * and the thing being described then come from the same place, and it is not the + * page asking for the signature. + * + * ## The identity check + * + * A sign request names a `genesisHash`. Endpoints are looked up by it, and an + * endpoint's answer is believed only if `chain_getBlockHash(0)` comes back equal + * to the hash that was asked for. Skipping that would replace "trust the dapp" + * with "trust this table", which is not obviously better. + */ +export default class ChainMetadata { + /** By genesis hash. Refetched when the chain's spec version moves on. */ + readonly #cache = new Map(); + /** In-flight fetches, so five queued requests make one connection. */ + readonly #inFlight = new Map>(); + + /** + * Metadata for a chain, or `null` when this extension has no endpoint for it. + * + * `null` is not a failure to report loudly: a user may legitimately be signing + * for a chain we do not know, and the caller turns it into a refusal with a + * message that says so. + */ + async fetch (genesisHash: string): Promise { + const cached = this.#cache.get(genesisHash); + + if (cached) { + return cached; + } + + const existing = this.#inFlight.get(genesisHash); + + if (existing) { + return existing; + } + + const pending = this.#fetch(genesisHash).finally(() => this.#inFlight.delete(genesisHash)); + + this.#inFlight.set(genesisHash, pending); + + return pending; + } + + /** Drop a chain's cached metadata, so the next request refetches it. */ + forget (genesisHash: string): void { + this.#cache.delete(genesisHash); + } + + async #fetch (genesisHash: string): Promise { + const chain = QUANTUS_CHAINS.find((c) => c.genesisHash === genesisHash); + + if (!chain) { + return null; + } + + for (const endpoint of chain.endpoints) { + try { + const def = await this.#fromEndpoint(endpoint, genesisHash, chain.name); + + this.#cache.set(genesisHash, def); + + return def; + } catch (error) { + // Try the next endpoint. One node being down is not the chain being + // unknown, and the two must not look alike to the caller. + console.error(`metadata from ${endpoint} failed: ${(error as Error).message}`); + } + } + + return null; + } + + async #fromEndpoint (endpoint: string, genesisHash: string, name: string): Promise { + const provider = new WsProvider(endpoint, false); + + try { + await provider.connect(); + await provider.isReady; + + const served = await provider.send('chain_getBlockHash', ['0x0']); + + // The whole point of the identity check. An endpoint that answers for a + // different chain would hand over metadata that decodes this chain's calls + // into something plausible and wrong. + if (served !== genesisHash) { + throw new Error(`serves ${served}, not ${genesisHash}`); + } + + const [version, metadata, properties] = await Promise.all([ + provider.send('state_getRuntimeVersion', []), + provider.send('state_getMetadata', []), + provider.send>('system_properties', []) + ]); + const first = (value: unknown): unknown => Array.isArray(value) ? (value as unknown[])[0] : value; + + return { + chain: name, + genesisHash: genesisHash as HexString, + icon: 'substrate', + rawMetadata: metadata, + specVersion: version.specVersion, + ss58Format: Number(properties['ss58Format'] ?? 189), + tokenDecimals: Number(first(properties['tokenDecimals']) ?? 12), + tokenSymbol: String(first(properties['tokenSymbol']) ?? ''), + types: {} + }; + } finally { + await provider.disconnect().catch(console.error); + } + } +} diff --git a/packages/extension-base/src/background/handlers/Extension.spec.ts b/packages/extension-base/src/background/handlers/Extension.spec.ts index 33b152b6..3704f5b7 100644 --- a/packages/extension-base/src/background/handlers/Extension.spec.ts +++ b/packages/extension-base/src/background/handlers/Extension.spec.ts @@ -53,6 +53,20 @@ describe('Extension', () => { // `type` is passed through rather than special-cased for ethereum: the default // is now ML-DSA, so a test that needs a derivable parent has to say so. + // `pub(extrinsic.sign)` is asynchronous before it queues anything: it fetches + // the chain's metadata first, so the approval screen has something to decode + // the call with. Reading allSignRequests straight after calling it is a race + // the test would lose. + const nextSignRequest = async () => { + const before = state.allSignRequests.length; + + for (let i = 0; i < 100 && state.allSignRequests.length === before; i++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + return state.allSignRequests[state.allSignRequests.length - 1]; + }; + const createAccount = async (type?: KeypairType): Promise => { await extension.handle('id', 'pri(accounts.create.suri)', { name: 'parent', @@ -229,8 +243,8 @@ describe('Extension', () => { // The newest request, not the oldest: requests from earlier tests in this // file are still queued, and approving one of those silently tests nothing. - const approve = () => extension.handle('1615192072290.7', 'pri(signing.approve.password)', { - id: state.allSignRequests[state.allSignRequests.length - 1].id, + const approve = async () => extension.handle('1615192072290.7', 'pri(signing.approve.password)', { + id: (await nextSignRequest()).id, password, savePass: false }, {} as chrome.runtime.Port); @@ -331,7 +345,7 @@ describe('Extension', () => { } as unknown as SignerPayloadJSON, 'http://localhost:3000', {} as chrome.runtime.Port) .catch((err) => console.log(err)); - const queued = state.allSignRequests[state.allSignRequests.length - 1]; + const queued = await nextSignRequest(); const res = await extension.handle('1615192062290.7', 'pri(signing.approve.password)', { id: queued.id, @@ -349,7 +363,7 @@ describe('Extension', () => { tabs.handle('1615191860871.8', 'pub(extrinsic.sign)', payload, 'http://localhost:3000', {} as chrome.runtime.Port) .catch((err) => console.log(err)); - const queued = state.allSignRequests[state.allSignRequests.length - 1]; + const queued = await nextSignRequest(); expect(queued.request.channel).toEqual('extrinsic'); diff --git a/packages/extension-base/src/background/handlers/State.ts b/packages/extension-base/src/background/handlers/State.ts index 1e2d486c..ae7b8baa 100644 --- a/packages/extension-base/src/background/handlers/State.ts +++ b/packages/extension-base/src/background/handlers/State.ts @@ -16,6 +16,7 @@ import { assert } from '@polkadot/util'; import { MetadataStore } from '../../stores/index.js'; import { getId } from '../../utils/getId.js'; +import ChainMetadata from '../ChainMetadata.js'; import { withErrorLog } from './helpers.js'; interface Resolver { @@ -135,6 +136,7 @@ export default class State { readonly #authRequests: Record = {}; readonly #metaStore = new MetadataStore(); + readonly #chainMetadata = new ChainMetadata(); // Map of providers currently injected in tabs readonly #injectedProviders = new Map(); @@ -609,6 +611,44 @@ export default class State { return provider.unsubscribe(request.type, request.method, request.subscriptionId); } + /** + * Make sure this extension can describe a chain before it is asked to sign for + * it, fetching the metadata from a node if nothing is known or what is known + * is for an older runtime. + * + * Silent on failure. A chain we have no endpoint for is a chain the signer + * will refuse, with a message that says so — there is nothing useful to say + * here that is not said better there. + */ + public async ensureMetadata (genesisHash: string, specVersion: number): Promise { + // Already able to describe the exact runtime this payload is for. The spec + // version is the test rather than mere presence: a runtime upgrade changes + // which calls and signed extensions exist, and metadata from before one + // decodes this chain's calls into something plausible and wrong — worse + // than not decoding them at all. + // + // This is also what keeps a signature request off the network in the common + // case, where the chain has not upgraded since the last one. + const known = this.knownMetadata.find((m) => m.genesisHash === genesisHash); + + if (known?.rawMetadata && known.specVersion === specVersion) { + return; + } + + try { + const fetched = await this.#chainMetadata.fetch(genesisHash); + + if (fetched) { + await this.saveMetadata(fetched); + } + } catch (error) { + // Not fatal and not reported here. A chain we cannot reach is a chain the + // signer refuses, with a message that says so; there is nothing useful to + // add at this point that is not said better there. + console.error(`Unable to fetch metadata for ${genesisHash}: ${(error as Error).message}`); + } + } + public async saveMetadata (meta: MetadataDef): Promise { await this.#metaStore.set(meta.genesisHash, meta); diff --git a/packages/extension-base/src/background/handlers/Tabs.ts b/packages/extension-base/src/background/handlers/Tabs.ts index f3bb5c73..0cb37624 100644 --- a/packages/extension-base/src/background/handlers/Tabs.ts +++ b/packages/extension-base/src/background/handlers/Tabs.ts @@ -17,7 +17,7 @@ import { combineLatest, type Subscription } from 'rxjs'; import { checkIfDenied } from '@polkadot/phishing'; import { keyring } from '@polkadot/ui-keyring'; import { accounts as accountsObservable } from '@polkadot/ui-keyring/observable/accounts'; -import { assert, isNumber } from '@polkadot/util'; +import { assert, hexToNumber, isNumber } from '@polkadot/util'; import { PHISHING_PAGE_REDIRECT } from '../../defaults.js'; import { canInject } from '../../utils/index.js'; @@ -140,7 +140,7 @@ export default class Tabs { return this.#state.sign(url, new RequestBytesSign(request), { address, ...pair.meta }); } - private extrinsicSign (url: string, request: SignerPayloadJSON): Promise { + private async extrinsicSign (url: string, request: SignerPayloadJSON): Promise { // matches the predicate the UI used to key off, so payloads that were // never ambiguous (absent, null, empty) keep working assert(!(request as unknown as SignerPayloadRaw).data, 'Unexpected raw data in a signPayload payload'); @@ -148,6 +148,15 @@ export default class Tabs { const address = request.address; const pair = this.getSigningPair(address); + // Before the popup opens, not after approval: the approval screen decodes + // the call with this metadata, and a user asked to approve a transaction + // rendered as raw hex has been given nothing to approve. + // + // Fetched from a node rather than taken from the dapp. The dapp already + // controls the transaction; letting it also supply the description would let + // it show one thing and have the user sign another. See ChainMetadata. + await this.#state.ensureMetadata(request.genesisHash, hexToNumber(request.specVersion)); + return this.#state.sign(url, new RequestExtrinsicSign(request), { address, ...pair.meta }); } diff --git a/packages/extension-base/src/defaults.ts b/packages/extension-base/src/defaults.ts index a4bd4920..35a9a40d 100644 --- a/packages/extension-base/src/defaults.ts +++ b/packages/extension-base/src/defaults.ts @@ -37,6 +37,35 @@ export const PHISHING_PAGE_REDIRECT = '/phishing-page-detected'; * * Verified reachable on 2026-09-15. */ +/** + * The chains this extension can fetch metadata for, by genesis hash. + * + * The genesis hash is the identity: a sign request names one, and the endpoint + * that answers is only believed if `chain_getBlockHash(0)` matches it. Without + * that check this table would be a list of hosts we take on trust to be the + * chain they claim, which is the same mistake as trusting a dapp's metadata. + * + * Verified reachable, and their genesis hashes read from the nodes, on + * 2026-09-16. + */ +export const QUANTUS_CHAINS = [ + { + endpoints: ['wss://rpc1-mainnet.quantus.com', 'wss://rpc2-mainnet.quantus.com'], + genesisHash: '0xfb5487c0be6ae4ade2d41d16e50465129861636c2b8d61fa94d7a19631626fba', + name: 'Quantus' + }, + { + endpoints: ['wss://a1-heisenberg.quantus.cat', 'wss://a2-heisenberg.quantus.cat'], + genesisHash: '0xa5aa9e5c84d4a3722c152295e7973c9af522f2fb1ef7db5afaa3d5f4dc8d3b4f', + name: 'Quantus Heisenberg' + }, + { + endpoints: ['wss://a1-planck.quantus.cat', 'wss://a2-planck.quantus.cat'], + genesisHash: '0x4901bf5c57fd3f9e726af399c763de6670dbdb115a91c0237e173f16eef65e72', + name: 'Quantus Planck' + } +] as const; + export const QUANTUS_ENDPOINTS = [ { text: 'Quantus', value: 'wss://rpc1-mainnet.quantus.com' }, { text: 'Heisenberg (testnet)', value: 'wss://a1-heisenberg.quantus.cat' },