diff --git a/jest.config.cjs b/jest.config.cjs index 96cf0a9e523a..923c36703ecd 100644 --- a/jest.config.cjs +++ b/jest.config.cjs @@ -22,8 +22,9 @@ module.exports = { '\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': 'empty/object', '\\.(md)$': '/jest/mocks/empty.js' }, + modulePathIgnorePatterns: ['/packages/apps-config/build'], setupFilesAfterEnv: ['/jest/jest-setup.ts'], testEnvironment: 'jsdom', testTimeout: 90000, - transformIgnorePatterns: ['/node_modules/(?!@polkadot|@babel/runtime/helpers/esm/)'] + transformIgnorePatterns: ['/node_modules/(?!@polkadot|@babel/runtime/helpers/esm/|@substrate|smoldot)'] }; diff --git a/packages/apps-config/src/endpoints/index.spec.ts b/packages/apps-config/src/endpoints/index.spec.ts index eb43e1fcff3d..0ac8c2455713 100644 --- a/packages/apps-config/src/endpoints/index.spec.ts +++ b/packages/apps-config/src/endpoints/index.spec.ts @@ -10,14 +10,20 @@ interface Endpoint { ws: string; } +interface LightClientEndpoint { + name: string; + param: string; +} + const allEndpoints = createWsEndpoints((k: string, v?: string) => v || k); -describe('urls are all valid', (): void => { +describe('WS urls are all valid', (): void => { allEndpoints .filter(({ value }) => value && isString(value) && - !value.includes('127.0.0.1') + !value.includes('127.0.0.1') && + !value.includes('substrate-connect') ) .map(({ text, value }): Endpoint => ({ name: text as string, @@ -30,6 +36,24 @@ describe('urls are all valid', (): void => { ); }); +describe('light client urls are all valid', (): void => { + allEndpoints + .filter(({ value }) => + value && + isString(value) && + value.includes('substrate-connect') + ) + .map(({ text, value }): LightClientEndpoint => ({ + name: text as string, + param: value + })) + .forEach(({ name, param }) => + it(`${name} @ ${param}`, (): void => { + assert(param.substr(param.indexOf('-')) === '-substrate-connect', `${name} @ ${param} should end with '-substrate-connect'`); + }) + ); +}); + describe('urls are sorted', (): void => { let hasDevelopment = false; let lastHeader = ''; diff --git a/packages/apps-config/src/endpoints/productionRelayKusama.ts b/packages/apps-config/src/endpoints/productionRelayKusama.ts index 24fab71e4d59..d16f2956898e 100644 --- a/packages/apps-config/src/endpoints/productionRelayKusama.ts +++ b/packages/apps-config/src/endpoints/productionRelayKusama.ts @@ -5,6 +5,7 @@ import type { TFunction } from 'i18next'; import type { EndpointOption } from './types'; import { KUSAMA_GENESIS } from '../api/constants'; +import { createProviderUrl } from './util'; /* eslint-disable sort-keys */ @@ -22,7 +23,8 @@ export function createKusama (t: TFunction): EndpointOption { providers: { Parity: 'wss://kusama-rpc.polkadot.io', OnFinality: 'wss://kusama.api.onfinality.io/public-ws', - 'Patract Elara': 'wss://kusama.elara.patract.io' + 'Patract Elara': 'wss://kusama.elara.patract.io', + 'light client': createProviderUrl('kusama-substrate-connect', 'substrate-connect') // Pinknode: 'wss://rpc.pinknode.io/kusama/explorer' // https://github.com/polkadot-js/apps/issues/5721 }, teleport: [1000], diff --git a/packages/apps-config/src/endpoints/productionRelayPolkadot.ts b/packages/apps-config/src/endpoints/productionRelayPolkadot.ts index d4827815f4a7..360d187c0edd 100644 --- a/packages/apps-config/src/endpoints/productionRelayPolkadot.ts +++ b/packages/apps-config/src/endpoints/productionRelayPolkadot.ts @@ -5,6 +5,7 @@ import type { TFunction } from 'i18next'; import type { EndpointOption } from './types'; import { POLKADOT_GENESIS } from '../api/constants'; +import { createProviderUrl } from './util'; /* eslint-disable sort-keys */ @@ -22,7 +23,8 @@ export function createPolkadot (t: TFunction): EndpointOption { providers: { Parity: 'wss://rpc.polkadot.io', OnFinality: 'wss://polkadot.api.onfinality.io/public-ws', - 'Patract Elara': 'wss://polkadot.elara.patract.io' + 'Patract Elara': 'wss://polkadot.elara.patract.io', + 'light client': createProviderUrl('polkadot-substrate-connect', 'substrate-connect') // Pinknode: 'wss://rpc.pinknode.io/polkadot/explorer' // https://github.com/polkadot-js/apps/issues/5721 }, linked: [ diff --git a/packages/apps-config/src/endpoints/testingRelayWestend.ts b/packages/apps-config/src/endpoints/testingRelayWestend.ts index c071a7c0d4bf..33c0fb4768bd 100644 --- a/packages/apps-config/src/endpoints/testingRelayWestend.ts +++ b/packages/apps-config/src/endpoints/testingRelayWestend.ts @@ -5,6 +5,7 @@ import type { TFunction } from 'i18next'; import type { EndpointOption } from './types'; import { WESTEND_GENESIS } from '../api/constants'; +import { createProviderUrl } from './util'; /* eslint-disable sort-keys */ @@ -24,7 +25,8 @@ export function createWestend (t: TFunction): EndpointOption { providers: { Parity: 'wss://westend-rpc.polkadot.io', 'Patract Elara': 'wss://westend.elara.patract.io', - OnFinality: 'wss://westend.api.onfinality.io/public-ws' + OnFinality: 'wss://westend.api.onfinality.io/public-ws', + 'light client': createProviderUrl('westend-substrate-connect', 'substrate-connect') // 'NodeFactory(Vedran)': 'wss://westend.vedran.nodefactory.io/ws', // https://github.com/polkadot-js/apps/issues/5580 // Pinknode: 'wss://rpc.pinknode.io/westend/explorer' // https://github.com/polkadot-js/apps/issues/5721 }, diff --git a/packages/apps-config/src/endpoints/types.ts b/packages/apps-config/src/endpoints/types.ts index 4af01500b12f..ca5a2d0eaf97 100644 --- a/packages/apps-config/src/endpoints/types.ts +++ b/packages/apps-config/src/endpoints/types.ts @@ -3,6 +3,8 @@ import type { Option } from '../settings/types'; +import { Endpoint } from '@polkadot/ui-settings/types'; + export interface EndpointOption { dnslink?: string; genesisHash?: string; @@ -14,7 +16,7 @@ export interface EndpointOption { linked?: EndpointOption[]; info?: string; paraId?: number; - providers: Record; + providers: Record; summary?: string; teleport?: number[]; text: React.ReactNode; @@ -27,6 +29,7 @@ export interface LinkOption extends Option { homepage?: string; isChild?: boolean; isDevelopment?: boolean; + isLightClient?: boolean; isRelay?: boolean; isUnreachable?: boolean; isSpaced?: boolean; diff --git a/packages/apps-config/src/endpoints/util.ts b/packages/apps-config/src/endpoints/util.ts index dd9b6f0813e8..84c2cc6b063f 100644 --- a/packages/apps-config/src/endpoints/util.ts +++ b/packages/apps-config/src/endpoints/util.ts @@ -4,6 +4,9 @@ import type { TFunction } from 'i18next'; import type { EndpointOption, LinkOption } from './types'; +import { Endpoint, EndpointType } from '@polkadot/ui-settings/types'; +import { isString } from '@polkadot/util'; + interface SortOption { isUnreachable?: boolean; } @@ -52,9 +55,12 @@ export function expandEndpoint (t: TFunction, { dnslink, genesisHash, homepage, .map(([host, value], index): LinkOption => ({ ...base, dnslink: index === 0 ? dnslink : undefined, + isLightClient: isString(value) ? false : value.type === 'substrate-connect', isRelay: false, - textBy: t('rpc.hosted.by', 'via {{host}}', { ns: 'apps-config', replace: { host } }), - value + textBy: isString(value) + ? t('rpc.hosted.by', 'hosted by {{host}}', { ns: 'apps-config', replace: { host } }) + : t('lightclient.experimental', 'light client (experimental)', { ns: 'apps-config' }), + value: isString(value) ? value : value.param })); if (linked) { @@ -78,3 +84,7 @@ export function expandEndpoint (t: TFunction, { dnslink, genesisHash, homepage, export function expandEndpoints (t: TFunction, input: EndpointOption[], firstOnly?: boolean): LinkOption[] { return input.sort(sortLinks).reduce((result: LinkOption[], input) => result.concat(expandEndpoint(t, input, firstOnly)), []); } + +export function createProviderUrl (param: string, type: EndpointType): Endpoint { + return { param, type }; +} diff --git a/packages/apps-config/src/settings/types.ts b/packages/apps-config/src/settings/types.ts index 43a051206415..e9291e4fc77a 100644 --- a/packages/apps-config/src/settings/types.ts +++ b/packages/apps-config/src/settings/types.ts @@ -8,3 +8,16 @@ export interface Option { text: React.ReactNode; value: string | number; } + +export interface LinkOption extends Option { + dnslink?: string; + genesisHash?: string; + genesisHashRelay?: string; + isChild?: boolean; + isDevelopment?: boolean; + isSpaced?: boolean; + isLightClient?: boolean; + linked?: LinkOption[]; + paraId?: number; + textBy: string; +} diff --git a/packages/apps/public/locales/en/apps-config.json b/packages/apps/public/locales/en/apps-config.json index 63697c8efbfd..901a486d305a 100644 --- a/packages/apps/public/locales/en/apps-config.json +++ b/packages/apps/public/locales/en/apps-config.json @@ -1,4 +1,5 @@ { + "lightclient.experimental": "light client (experimental)", "lng.detect": "Default browser language (auto-detect)", "rpc.KlugDossier": "Klug Dossier", "rpc.dev.custom": "Custom environment", diff --git a/packages/apps/src/Endpoints/Network.tsx b/packages/apps/src/Endpoints/Network.tsx index d8798d265ba7..2ebecb91c6fd 100644 --- a/packages/apps/src/Endpoints/Network.tsx +++ b/packages/apps/src/Endpoints/Network.tsx @@ -25,7 +25,11 @@ function NetworkDisplay ({ apiUrl, className = '', setApiUrl, value: { icon, isC ); const _selectUrl = useCallback( - () => setApiUrl(name, providers[Math.floor(Math.random() * providers.length)].url), + () => { + const filteredProviders = providers.filter((p) => !p.name.startsWith('light client')); + + return setApiUrl(name, filteredProviders[Math.floor(Math.random() * filteredProviders.length)].url); + }, [name, providers, setApiUrl] ); diff --git a/packages/apps/src/Endpoints/index.tsx b/packages/apps/src/Endpoints/index.tsx index ef63f7326f79..e06ed3606451 100644 --- a/packages/apps/src/Endpoints/index.tsx +++ b/packages/apps/src/Endpoints/index.tsx @@ -14,6 +14,7 @@ import styled from 'styled-components'; import { createWsEndpoints, CUSTOM_ENDPOINT_KEY } from '@polkadot/apps-config'; import { Button, Input, Sidebar } from '@polkadot/react-components'; import { settings } from '@polkadot/ui-settings'; +import { Endpoint, EndpointType } from '@polkadot/ui-settings/types'; import { isAscii } from '@polkadot/util'; import { useTranslation } from '../translate'; @@ -43,13 +44,21 @@ function isValidUrl (url: string): boolean { ); } +function getApiType (param: string): Endpoint { + if (param.includes('-substrate-connect')) { + return { param, type: 'substrate-connect' }; + } + + return { param, type: 'json-rpc' }; +} + function combineEndpoints (endpoints: LinkOption[]): Group[] { return endpoints.reduce((result: Group[], e): Group[] => { if (e.isHeader) { result.push({ header: e.text, isDevelopment: e.isDevelopment, isSpaced: e.isSpaced, networks: [] }); } else { const prev = result[result.length - 1]; - const prov = { name: e.textBy, url: e.value }; + const prov = { isLightClient: e.isLightClient, name: e.textBy, url: e.value }; if (prev.networks[prev.networks.length - 1] && e.text === prev.networks[prev.networks.length - 1].name) { prev.networks[prev.networks.length - 1].providers.push(prov); @@ -118,6 +127,18 @@ function loadAffinities (groups: Group[]): Record { }), {}); } +function isSwitchDisabled (hasUrlChanged: boolean, apiType: EndpointType, isUrlValid: boolean): boolean { + if (!hasUrlChanged) { + return true; + } else if (apiType === 'substrate-connect') { + return false; + } else if (isUrlValid) { + return false; + } + + return true; +} + function Endpoints ({ className = '', offset, onClose }: Props): React.ReactElement { const { t } = useTranslation(); const linkOptions = createWsEndpoints(t); @@ -216,21 +237,24 @@ function Endpoints ({ className = '', offset, onClose }: Props): React.ReactElem const _onApply = useCallback( (): void => { settings.set({ ...(settings.get()), apiUrl }); - - window.location.assign(`${window.location.origin}${window.location.pathname}?rpc=${encodeURIComponent(apiUrl)}${window.location.hash}`); + window.location.assign(`${window.location.origin}${window.location.pathname}${getApiType(apiUrl).type === 'substrate-connect' ? '?sc=' : '?rpc='}${encodeURIComponent(apiUrl)}${window.location.hash}`); // window.location.reload(); - onClose(); }, [apiUrl, onClose] ); + const canSwitch = useMemo( + () => isSwitchDisabled(hasUrlChanged, getApiType(apiUrl).type, isUrlValid), + [hasUrlChanged, apiUrl, isUrlValid] + ); + return ( ('Switch')} onClick={_onApply} /> @@ -251,7 +275,7 @@ function Endpoints ({ className = '', offset, onClose }: Props): React.ReactElem setGroup={_changeGroup} value={group} > - {group.isDevelopment && ( + {group.isDevelopment && getApiType(apiUrl).type === 'json-rpc' && (
{ diff --git a/packages/apps/src/initSettings.ts b/packages/apps/src/initSettings.ts index 88e622d2625b..f26cf0bee339 100644 --- a/packages/apps/src/initSettings.ts +++ b/packages/apps/src/initSettings.ts @@ -7,9 +7,18 @@ import store from 'store'; import { createWsEndpoints } from '@polkadot/apps-config'; import { extractIpfsDetails } from '@polkadot/react-hooks/useIpfs'; import { settings } from '@polkadot/ui-settings'; +import { Endpoint } from '@polkadot/ui-settings/types'; import { assert } from '@polkadot/util'; -function getApiUrl (): string { +function networkOrUrl (apiType: Endpoint): void { + if (apiType.type === 'json-rpc') { + console.log('WS endpoint=', apiType.param); + } else if (apiType.type === 'substrate-connect') { + console.log('Chain of light client is =', apiType.param); + } +} + +function getApiType (): Endpoint { // we split here so that both these forms are allowed // - http://localhost:3000/?rpc=wss://substrate-rpc.parity.io/#/explorer // - http://localhost:3000/#/explorer?rpc=wss://substrate-rpc.parity.io @@ -24,7 +33,15 @@ function getApiUrl (): string { assert(url.startsWith('ws://') || url.startsWith('wss://'), 'Non-prefixed ws/wss url'); - return url; + return { param: url, type: 'json-rpc' }; + } else if (urlOptions.sc) { + assert(!Array.isArray(urlOptions.sc), 'Invalid network specified'); + + // https://polkadot.js.org/apps/?sc=kusama#/explorer; + const network = decodeURIComponent(urlOptions.sc.split('#')[0]); + const chain = network.split('-')[0]; + + return { param: chain, type: 'substrate-connect' }; } const endpoints = createWsEndpoints((): T => ('' as unknown as T)); @@ -35,7 +52,7 @@ function getApiUrl (): string { const option = endpoints.find(({ dnslink }) => dnslink === ipnsChain); if (option) { - return option.value; + return { param: option.value, type: 'json-rpc' }; } } @@ -43,16 +60,17 @@ function getApiUrl (): string { const fallbackUrl = endpoints.find(({ value }) => !!value); // via settings, or the default chain - return [stored.apiUrl, process.env.WS_URL].includes(settings.apiUrl) - ? settings.apiUrl // keep as-is + return [stored.apiType, process.env.WS_URL].includes(settings.apiType) + ? settings.apiType // keep as-is : fallbackUrl - ? fallbackUrl.value // grab the fallback - : 'ws://127.0.0.1:9944'; // nothing found, go local + ? { param: fallbackUrl.value, type: 'json-rpc' } // grab the fallback + : { param: 'ws://127.0.0.1:9944', type: 'json-rpc' }; // nothing found, go local } -const apiUrl = getApiUrl(); +// There cannot be a Substrate Connect light client default (expect only jrpc EndpointType) +const apiType = getApiType(); // set the default as retrieved here -settings.set({ apiUrl }); +settings.set({ apiType }); -console.log('WS endpoint=', apiUrl); +networkOrUrl(apiType); diff --git a/packages/apps/src/overlays/Connecting.tsx b/packages/apps/src/overlays/Connecting.tsx index f97c44d47672..92fc9957e1ec 100644 --- a/packages/apps/src/overlays/Connecting.tsx +++ b/packages/apps/src/overlays/Connecting.tsx @@ -9,8 +9,8 @@ import { settings } from '@polkadot/ui-settings'; import { useTranslation } from '../translate'; import BaseOverlay from './Base'; -const wsUrl = settings.apiUrl; -const isWs = typeof wsUrl === 'string' && wsUrl.startsWith('ws://'); +const wsUrl = settings.apiType.param; +const isWs = settings.apiType.type === 'json-rpc' && typeof wsUrl === 'string' && wsUrl.startsWith('ws://'); const isWsLocal = typeof wsUrl === 'string' && wsUrl.includes('127.0.0.1'); const isHttps = window.location.protocol.startsWith('https:'); diff --git a/packages/page-accounts/src/CreateAccount.slow.spec.tsx b/packages/page-accounts/src/CreateAccount.slow.spec.tsx index 8d374a004d0b..b1e8002fe307 100644 --- a/packages/page-accounts/src/CreateAccount.slow.spec.tsx +++ b/packages/page-accounts/src/CreateAccount.slow.spec.tsx @@ -14,15 +14,19 @@ import { Api } from '@polkadot/react-api'; import { MemoryStore } from '@polkadot/test-support/keyring'; import { WaitForApi } from '@polkadot/test-support/react'; import { SUBSTRATE_PORT } from '@polkadot/test-support/substrate'; +import { Endpoint } from '@polkadot/ui-settings/types'; const renderAccounts = () => { const memoryStore = new MemoryStore(); + const apiType: Endpoint = { param: `ws://127.0.0.1:${SUBSTRATE_PORT}`, type: 'json-rpc' }; return render( - +
{ const memoryStore = new MemoryStore(); + const apiType: Endpoint = { param: `ws://127.0.0.1:${SUBSTRATE_PORT}`, type: 'json-rpc' }; + return render( - +
diff --git a/packages/react-api/__mocks__/@substrate/connect.js b/packages/react-api/__mocks__/@substrate/connect.js new file mode 100644 index 000000000000..3dde70808300 --- /dev/null +++ b/packages/react-api/__mocks__/@substrate/connect.js @@ -0,0 +1,4 @@ +// Copyright 2019-2020 @polkadot/extension authors & contributors +// SPDX-License-Identifier: Apache-2.0 + +module.exports = ''; diff --git a/packages/react-api/src/Api.tsx b/packages/react-api/src/Api.tsx index 895d65cc80cc..d58ed4f72b8f 100644 --- a/packages/react-api/src/Api.tsx +++ b/packages/react-api/src/Api.tsx @@ -7,9 +7,11 @@ import type { ChainProperties, ChainType } from '@polkadot/types/interfaces'; import type { KeyringStore } from '@polkadot/ui-keyring/types'; import type { ApiProps, ApiState } from './types'; +import { Detector } from '@substrate/connect'; import React, { useContext, useEffect, useMemo, useState } from 'react'; import store from 'store'; +import { WsProvider } from '@polkadot/api'; import { ApiPromise } from '@polkadot/api/promise'; import { deriveMapCache, setDeriveCache } from '@polkadot/api-derive/util'; import { ethereumChains, typesBundle, typesChain } from '@polkadot/apps-config'; @@ -17,9 +19,10 @@ import { web3Accounts, web3Enable } from '@polkadot/extension-dapp'; import { TokenUnit } from '@polkadot/react-components/InputNumber'; import { StatusContext } from '@polkadot/react-components/Status'; import ApiSigner from '@polkadot/react-signer/signers/ApiSigner'; -import { WsProvider } from '@polkadot/rpc-provider'; +import { ProviderInterface } from '@polkadot/rpc-provider/types'; import { keyring } from '@polkadot/ui-keyring'; import { settings } from '@polkadot/ui-settings'; +import { Endpoint } from '@polkadot/ui-settings/types'; import { formatBalance, isTestChain } from '@polkadot/util'; import { defaults as addressDefaults } from '@polkadot/util-crypto/address/defaults'; @@ -29,7 +32,7 @@ import { decodeUrlTypes } from './urlTypes'; interface Props { children: React.ReactNode; - url?: string; + apiType: Endpoint; store?: KeyringStore; } @@ -177,7 +180,7 @@ async function loadOnReady (api: ApiPromise, injectedPromise: Promise | null { +function Api ({ apiType, children, store }: Props): React.ReactElement | null { const { queuePayload, queueSetTxStatus } = useContext(StatusContext); const [state, setState] = useState({ hasInjectedAccounts: false, isApiReady: false } as unknown as ApiState); const [isApiConnected, setIsApiConnected] = useState(false); @@ -186,13 +189,24 @@ function Api ({ children, store, url }: Props): React.ReactElement | null const [extensions, setExtensions] = useState(); const value = useMemo( - () => ({ ...state, api, apiError, apiUrl: url, extensions, isApiConnected, isApiInitialized, isWaitingInjected: !extensions }), - [apiError, extensions, isApiConnected, isApiInitialized, state, url] + () => ({ ...state, api, apiError, apiUrl: apiType.param, extensions, isApiConnected, isApiInitialized, isWaitingInjected: !extensions }), + [apiError, extensions, isApiConnected, isApiInitialized, state, apiType] ); // initial initialization useEffect((): void => { - const provider = new WsProvider(url); + let provider; + + if (apiType.type === 'substrate-connect') { + const detect: Detector = new Detector('PolkadotJS apps'); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + provider = detect.provider(apiType.param) as ProviderInterface; + provider.connect().catch(console.error); + } else if (apiType.type === 'json-rpc') { + provider = new WsProvider(apiType.param); + } + const signer = new ApiSigner(registry, queuePayload, queueSetTxStatus); const types = getDevTypes(); @@ -218,8 +232,7 @@ function Api ({ children, store, url }: Props): React.ReactElement | null }); setIsApiInitialized(true); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [apiType, queuePayload, queueSetTxStatus, store]); if (!value.isApiInitialized) { return null;