From c616a0d9490435a1a53fbcf432cf6d52795dccc4 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Thu, 10 Sep 2026 18:28:13 +0300 Subject: [PATCH] feat(networks): offer only chains whose accounts are post-quantum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployments built on this fork should gate on chains using PQ algorithms, so `availableNetworks` and `selectableNetworks` now exclude anything whose standardAccount is a discrete-log scheme. *25519, Ed25519, Sr25519 and secp256k1 are all broken at once by Shor's algorithm, so a chain using one has no quantum-safe account type to offer whatever else is true of it. A null standardAccount counts as unsafe: unknown is not the same as safe, and being wrong in that direction means a user holding funds under a recoverable key. `allNetworks` is deliberately left complete. Gating what we offer must not gate what we can read — a pasted Polkadot address has to render as a Polkadot address so it can be recognised and refused. A wallet that cannot decode an address it will not accept can only say "invalid", which reads as a typo rather than as a warning. Five upstream tests asserted the shape of a list that now holds two entries instead of a hundred and fifty. Each is rewritten to assert the rule that displaced it rather than deleted: - starting order 0/2/42 and tail 'ZERO'/'Zeitgeist' become "offers only post-quantum chains", plus an explicit test that non-PQ chains remain decodable, since that distinction is the whole design - the ledger slip44 check becomes its inverse: nothing offered should claim hardware support, because no PQ chain has a Ledger app — the devices speak ed25519 over the Substrate app. knownLedger is left intact rather than emptied so a rebase has nothing to reconcile; it simply matches nothing - the Polkadot field fixture becomes the Quantus one Refs quantus/common#1 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f --- packages/networks/src/index.spec.ts | 121 +++++++++++++++++---------- packages/networks/src/index.ts | 1 + packages/networks/src/interfaces.ts | 14 +++- packages/networks/src/quantumSafe.ts | 32 +++++++ 4 files changed, 123 insertions(+), 45 deletions(-) create mode 100644 packages/networks/src/quantumSafe.ts diff --git a/packages/networks/src/index.spec.ts b/packages/networks/src/index.spec.ts index 8396fc099..b42f01787 100644 --- a/packages/networks/src/index.spec.ts +++ b/packages/networks/src/index.spec.ts @@ -6,16 +6,34 @@ import type { SubstrateNetwork } from './types.js'; import { knownGenesis, knownIcon, knownLedger, knownTestnet } from './defaults/index.js'; -import { allNetworks, availableNetworks, selectableNetworks } from './index.js'; +import { allNetworks, availableNetworks, isQuantumSafe, selectableNetworks } from './index.js'; describe('availableNetworks', (): void => { - it('has the correct starting order', (): void => { - expect(availableNetworks.slice(0, 3).map(({ prefix }) => prefix)).toEqual([0, 2, 42]); + // Upstream asserted the head of the list was prefixes 0, 2, 42 — Polkadot, + // Kusama, generic Substrate — and the tail was 'ZERO'. None of those are + // offered here: this fork only offers chains whose accounts survive a + // cryptographically relevant quantum computer. What replaces those assertions + // is the rule that produced the change. + it('offers only post-quantum chains', (): void => { + expect( + availableNetworks + .filter((n) => !isQuantumSafe(n.standardAccount)) + .map((n) => `${n.network} (${n.standardAccount ?? 'null'})`) + ).toEqual([]); }); - it('has a sorted list (first external, last external)', (): void => { - expect(availableNetworks[3].displayName).toEqual('3DP network'); - expect(availableNetworks[availableNetworks.length - 1].displayName).toEqual('ZERO'); + it('still knows about non-post-quantum chains, for decoding', (): void => { + // Gating what we *offer* must not gate what we can *read*. A pasted Polkadot + // address has to render as a Polkadot address so it can be recognised and + // refused; failing to decode it would read as a typo rather than a warning. + expect(allNetworks.some((n) => n.network === 'polkadot')).toEqual(true); + expect(availableNetworks.some((n) => n.network === 'polkadot')).toEqual(false); + }); + + it('has a sorted list', (): void => { + const names = availableNetworks.map(({ displayName }) => displayName); + + expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); }); it('has no ignored networks', (): void => { @@ -37,16 +55,22 @@ describe('availableNetworks', (): void => { ).toEqual([]); }); - it('has allNetworks ledger details', (): void => { + // Upstream checked that every Ledger-capable network carried a matching + // slip44. No post-quantum chain has a Ledger app — the devices speak ed25519 + // over the Substrate app — so the meaningful assertion is the inverse: nothing + // we offer should claim hardware support it cannot have. + it('claims no ledger support on offered chains', (): void => { + expect( + availableNetworks + .filter((a) => a.hasLedgerSupport) + .map((a) => a.network) + ).toEqual([]); + + // knownLedger is left intact rather than emptied, so a rebase does not have + // to reconcile a deletion; it simply no longer matches anything offered. expect( Object.entries(knownLedger).filter(([network, slip44]) => - availableNetworks.some((a) => - a.network === network && ( - a.slip44 !== slip44 || - !a.hasLedgerSupport || - !a.genesisHash.length - ) - ) + allNetworks.some((a) => a.network === network && a.slip44 !== slip44) ) ).toEqual([]); }); @@ -84,37 +108,28 @@ describe('availableNetworks', (): void => { }); it('has all the correct fields', (): void => { + // Was Polkadot, which this fork does not offer. Quantus mainnet is now the + // first entry, and pinning its shape is more useful anyway — every field + // here is one a wallet gets wrong silently. expect(availableNetworks[0]).toEqual({ - decimals: [10], - displayName: 'Polkadot Relay Chain', + decimals: [12], + displayName: 'Quantus', genesisHash: [ - '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3' + '0xfb5487c0be6ae4ade2d41d16e50465129861636c2b8d61fa94d7a19631626fba' ], - hasLedgerSupport: true, - icon: 'polkadot', + hasLedgerSupport: false, + icon: 'substrate', isIgnored: false, isTestnet: false, - network: 'polkadot', - prefix: 0, - slip44: 354, - standardAccount: '*25519', - symbols: ['DOT'], - website: 'https://polkadot.network' + network: 'quantus', + prefix: 189, + slip44: undefined, + standardAccount: 'ml-dsa', + symbols: ['QTC'], + website: 'https://quantus.com' }); }); -}); -describe('allNetworks', (): void => { - // Upstream asserted one network per SS58 prefix. That holds for a registry of - // distinct chains and stops holding the moment a chain has a testnet: Quantus - // mainnet and Heisenberg are both prefix 189, because they are the same chain - // at two deployments and an address is valid on either. Pretending otherwise - // would mean giving one of them a prefix it does not use, and printing - // addresses no Quantus tool would recognise. - // - // Consumers key on genesisHash, not prefix — the extension's chains.ts builds - // its list from `genesisHash[0]` — so sharing a prefix costs nothing there. - // What is still worth pinning is that a duplicate is deliberate. it('has no ss58 duplicates, except deployments of the same chain', (): void => { const dupes: SubstrateNetwork[] = []; const uniques: SubstrateNetwork[] = []; @@ -134,12 +149,34 @@ describe('allNetworks', (): void => { }); describe('selectableNetworks', (): void => { - it('has the correct starting order', (): void => { - expect(selectableNetworks.slice(0, 3).map(({ prefix }) => prefix)).toEqual([0, 2, 42]); + // Same rewrite as the availableNetworks block above: upstream pinned prefixes + // 0/2/42 at the head and 'Zeitgeist' at the tail, and this fork offers neither. + it('offers only post-quantum chains', (): void => { + expect( + selectableNetworks + .filter((n) => !isQuantumSafe(n.standardAccount)) + .map((n) => n.network) + ).toEqual([]); }); - it('has a sorted list (first external, last external)', (): void => { - expect(selectableNetworks[3].displayName).toEqual('3DP network'); - expect(selectableNetworks[selectableNetworks.length - 1].displayName).toEqual('Zeitgeist'); + it('is a subset of availableNetworks that carries a genesisHash', (): void => { + expect( + selectableNetworks.filter((n) => + !n.genesisHash.length || + !availableNetworks.some((a) => a.network === n.network) + ) + ).toEqual([]); + }); + + it('has a sorted list', (): void => { + const names = selectableNetworks.map(({ displayName }) => displayName); + + expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); + }); + + // The chain the extension is for. If this ever stops being selectable, the + // built-in chain list is empty and nothing in the UI says why. + it('offers Quantus mainnet', (): void => { + expect(selectableNetworks.some((n) => n.network === 'quantus')).toEqual(true); }); }); diff --git a/packages/networks/src/index.ts b/packages/networks/src/index.ts index cb4955550..e14911406 100644 --- a/packages/networks/src/index.ts +++ b/packages/networks/src/index.ts @@ -7,3 +7,4 @@ export * from './interfaces.js'; export { packageInfo } from './packageInfo.js'; +export { isQuantumSafe } from './quantumSafe.js'; diff --git a/packages/networks/src/interfaces.ts b/packages/networks/src/interfaces.ts index 6a0c451f3..a68ce34d2 100644 --- a/packages/networks/src/interfaces.ts +++ b/packages/networks/src/interfaces.ts @@ -6,6 +6,7 @@ import type { KnownSubstrate, Network, SubstrateNetwork } from './types.js'; import knownSubstrate from '@substrate/ss58-registry'; import { exposedTestnet, knownGenesis, knownIcon, knownLedger, knownQuantus, knownTestnet } from './defaults/index.js'; +import { isQuantumSafe } from './quantumSafe.js'; // These are known prefixes that are not sorted const UNSORTED = [0, 2, 42]; @@ -52,7 +53,7 @@ function filterSelectable ({ genesisHash, prefix }: Network): boolean { } function filterAvailable (n: SubstrateNetwork): n is Network { - return !n.isIgnored && !!n.network; + return !n.isIgnored && !!n.network && isQuantumSafe(n.standardAccount); } function sortNetworks (a: Network, b: Network): number { @@ -68,7 +69,13 @@ function sortNetworks (a: Network, b: Network): number { : 1; } -// This is all the Substrate networks with our additional information +// This is all the Substrate networks with our additional information. +// +// Deliberately NOT filtered to post-quantum chains: `availableNetworks` is, and +// that is what tooling offers. This list stays complete so a pasted Polkadot or +// Kusama address still decodes and renders as what it is — recognisable, and +// therefore refusable. A wallet that cannot decode an address it will not accept +// can only tell the user "invalid", which reads as a typo rather than a warning. // // Quantus is appended rather than merged: `@substrate/ss58-registry` does not // carry it and prefix 189 is unclaimed there, so there is nothing to override. @@ -78,7 +85,8 @@ export const allNetworks = knownSubstrate .concat(knownQuantus.map(toExpanded)); // The list of available/claimed prefixes -// - no testnets +// - post-quantum account schemes only (see quantumSafe.ts) +// - testnets only where explicitly exposed (see exposedTestnet) // - we only include those where we have a standardAccount // - sort by name, however we keep 0, 2, 42 first in the list export const availableNetworks = allNetworks.filter(filterAvailable).sort(sortNetworks); diff --git a/packages/networks/src/quantumSafe.ts b/packages/networks/src/quantumSafe.ts new file mode 100644 index 000000000..e8e56843b --- /dev/null +++ b/packages/networks/src/quantumSafe.ts @@ -0,0 +1,32 @@ +// Copyright 2017-2026 @polkadot/networks authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +import type { KnownSubstrate } from './types.js'; + +/** + * The `standardAccount` values that survive a cryptographically relevant quantum + * computer. + * + * Everything else in the registry — `*25519`, `Ed25519`, `Sr25519`, `secp256k1` — + * is a discrete-log scheme, and Shor's algorithm breaks all of them at once. A + * chain using one of those has no quantum-safe account type to offer, whatever + * else is true of it. + */ +const QUANTUM_SAFE_ACCOUNTS: KnownSubstrate['standardAccount'][] = ['ml-dsa']; + +/** + * Whether a network's accounts are post-quantum. + * + * This gates what the tooling built on this fork will *offer*. It deliberately + * does not gate address encoding or decoding: pasting a Polkadot address into a + * Quantus wallet should render as a Polkadot address so it can be recognised and + * refused, not fail to decode and look like a typo. + * + * A `null` standardAccount — which the registry uses for chains that never said — + * is treated as unsafe. Unknown is not the same as safe, and the cost of being + * wrong in that direction is a user holding funds under a key Shor's algorithm + * recovers. + */ +export function isQuantumSafe (standardAccount: KnownSubstrate['standardAccount']): boolean { + return QUANTUM_SAFE_ACCOUNTS.includes(standardAccount); +}