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' },