polkadot-js/apps

View on GitHub
packages/react-api/src/Api.tsx

Summary

Maintainability
C
1 day
Test Coverage
// Copyright 2017-2024 @polkadot/react-api authors & contributors
// SPDX-License-Identifier: Apache-2.0

import type { Blockchain } from '@acala-network/chopsticks-core';
import type { LinkOption } from '@polkadot/apps-config/endpoints/types';
import type { InjectedExtension } from '@polkadot/extension-inject/types';
import type { ChainProperties, ChainType } from '@polkadot/types/interfaces';
import type { KeyringStore } from '@polkadot/ui-keyring/types';
import type { ApiProps, ApiState, InjectedAccountExt } from './types.js';

import { ChopsticksProvider, setStorage } from '@acala-network/chopsticks-core';
import * as Sc from '@substrate/connect';
import React, { useEffect, useMemo, useState } from 'react';
import store from 'store';

import { ApiPromise, ScProvider, WsProvider } from '@polkadot/api';
import { deriveMapCache, setDeriveCache } from '@polkadot/api-derive/util';
import { ethereumChains, typesBundle } from '@polkadot/apps-config';
import { web3Accounts, web3Enable } from '@polkadot/extension-dapp';
import { TokenUnit } from '@polkadot/react-components/InputConsts/units';
import { useApiUrl, useEndpoint, usePeopleEndpoint, useQueue } from '@polkadot/react-hooks';
import { ApiCtx } from '@polkadot/react-hooks/ctx/Api';
import { ApiSigner } from '@polkadot/react-signer/signers';
import { keyring } from '@polkadot/ui-keyring';
import { settings } from '@polkadot/ui-settings';
import { formatBalance, isNumber, isTestChain, objectSpread, stringify } from '@polkadot/util';
import { defaults as addressDefaults } from '@polkadot/util-crypto/address/defaults';

import { lightSpecs, relaySpecs } from './light/index.js';
import { statics } from './statics.js';
import { decodeUrlTypes } from './urlTypes.js';

interface Props {
  children: React.ReactNode;
  apiUrl: string;
  isElectron: boolean;
  store?: KeyringStore;
}

interface ChainData {
  injectedAccounts: InjectedAccountExt[];
  properties: ChainProperties;
  systemChain: string;
  systemChainType: ChainType;
  systemName: string;
  systemVersion: string;
}

interface CreateApiReturn {
  types: Record<string, Record<string, string>>;
  fork: Blockchain | null;
}

export const DEFAULT_DECIMALS = statics.registry.createType('u32', 12);
export const DEFAULT_SS58 = statics.registry.createType('u32', addressDefaults.prefix);
export const DEFAULT_AUX = ['Aux1', 'Aux2', 'Aux3', 'Aux4', 'Aux5', 'Aux6', 'Aux7', 'Aux8', 'Aux9'];

const DISALLOW_EXTENSIONS: string[] = [];
const EMPTY_STATE = { hasInjectedAccounts: false, isApiReady: false } as unknown as ApiState;

function isKeyringLoaded () {
  try {
    return !!keyring.keyring;
  } catch {
    return false;
  }
}

function getDevTypes (): Record<string, Record<string, string>> {
  const types = decodeUrlTypes() || store.get('types', {}) as Record<string, Record<string, string>>;
  const names = Object.keys(types);

  names.length && console.log('Injected types:', names.join(', '));

  return types;
}

async function getInjectedAccounts (injectedPromise: Promise<InjectedExtension[]>): Promise<InjectedAccountExt[]> {
  try {
    await injectedPromise;

    const accounts = await web3Accounts();

    return accounts.map(({ address, meta }, whenCreated): InjectedAccountExt => ({
      address,
      meta: objectSpread({}, meta, {
        name: `${meta.name || 'unknown'} (${meta.source === 'polkadot-js' ? 'extension' : meta.source})`,
        whenCreated
      })
    }));
  } catch (error) {
    console.error('web3Accounts', error);

    return [];
  }
}

function makeCreateLink (baseApiUrl: string, isElectron: boolean): (path: string) => string {
  return (path: string, apiUrl?: string): string =>
    `${isElectron
      ? 'https://polkadot.js.org/apps/'
      : `${window.location.origin}${window.location.pathname}`
    }?rpc=${encodeURIComponent(apiUrl || baseApiUrl)}#${path}`;
}

async function retrieve (api: ApiPromise, injectedPromise: Promise<InjectedExtension[]>): Promise<ChainData> {
  const [systemChain, systemChainType, systemName, systemVersion, injectedAccounts] = await Promise.all([
    api.rpc.system.chain(),
    api.rpc.system.chainType
      ? api.rpc.system.chainType()
      : Promise.resolve(statics.registry.createType('ChainType', 'Live')),
    api.rpc.system.name(),
    api.rpc.system.version(),
    getInjectedAccounts(injectedPromise)
  ]);

  return {
    injectedAccounts: injectedAccounts.filter(({ meta: { source } }) =>
      !DISALLOW_EXTENSIONS.includes(source)
    ),
    properties: statics.registry.createType('ChainProperties', {
      isEthereum: api.registry.chainIsEthereum,
      ss58Format: api.registry.chainSS58,
      tokenDecimals: api.registry.chainDecimals,
      tokenSymbol: api.registry.chainTokens
    }),
    systemChain: (systemChain || '<unknown>').toString(),
    systemChainType,
    systemName: systemName.toString(),
    systemVersion: systemVersion.toString()
  };
}

async function loadOnReady (api: ApiPromise, endpoint: LinkOption | null, fork: Blockchain | null, injectedPromise: Promise<InjectedExtension[]>, store: KeyringStore | undefined, types: Record<string, Record<string, string>>, urlIsEthereum = false): Promise<ApiState> {
  statics.registry.register(types);

  const { injectedAccounts, properties, systemChain, systemChainType, systemName, systemVersion } = await retrieve(api, injectedPromise);
  const chainSS58 = properties.ss58Format.unwrapOr(DEFAULT_SS58).toNumber();
  const ss58Format = settings.prefix === -1
    ? chainSS58
    : settings.prefix;
  const tokenSymbol = properties.tokenSymbol.unwrapOr([formatBalance.getDefaults().unit, ...DEFAULT_AUX]);
  const tokenDecimals = properties.tokenDecimals.unwrapOr([DEFAULT_DECIMALS]);
  const isEthereum = properties.isEthereum.isTrue || ethereumChains.includes(api.runtimeVersion.specName.toString()) || urlIsEthereum;
  const isDevelopment = (systemChainType.isDevelopment || systemChainType.isLocal || isTestChain(systemChain));

  console.log(`chain: ${systemChain} (${systemChainType.toString()}), ${stringify(properties)}`);

  // explicitly override the ss58Format as specified
  statics.registry.setChainProperties(
    statics.registry.createType('ChainProperties', {
      isEthereum,
      ss58Format,
      tokenDecimals,
      tokenSymbol
    })
  );

  // first setup the UI helpers
  formatBalance.setDefaults({
    decimals: tokenDecimals.map((b) => b.toNumber()),
    unit: tokenSymbol[0].toString()
  });
  TokenUnit.setAbbr(tokenSymbol[0].toString());

  // finally load the keyring
  isKeyringLoaded() || keyring.loadAll({
    genesisHash: api.genesisHash,
    genesisHashAdd: endpoint && isNumber(endpoint.paraId) && (endpoint.paraId < 2000) && endpoint.genesisHashRelay
      ? [endpoint.genesisHashRelay]
      : [],
    isDevelopment,
    ss58Format,
    store,
    type: isEthereum ? 'ethereum' : 'ed25519'
  }, injectedAccounts);

  const defaultSection = Object.keys(api.tx)[0];
  const defaultMethod = Object.keys(api.tx[defaultSection])[0];
  const apiDefaultTx = api.tx[defaultSection][defaultMethod];
  const apiDefaultTxSudo = api.tx.system?.setCode || apiDefaultTx;

  setDeriveCache(api.genesisHash.toHex(), deriveMapCache);

  return {
    apiDefaultTx,
    apiDefaultTxSudo,
    chainSS58,
    fork,
    hasInjectedAccounts: injectedAccounts.length !== 0,
    isApiReady: true,
    isDevelopment,
    isEthereum,
    specName: api.runtimeVersion.specName.toString(),
    specVersion: api.runtimeVersion.specVersion.toString(),
    systemChain,
    systemName,
    systemVersion
  };
}

/**
 * @internal
 * Creates a ScProvider from a <relay>[/parachain] string
 */
async function getLightProvider (chain: string): Promise<ScProvider> {
  const [sc, relayName, paraName] = chain.split('/');

  if (sc !== 'substrate-connect') {
    throw new Error(`Cannot connect to non substrate-connect protocol ${chain}`);
  } else if (!relaySpecs[relayName] || (paraName && !lightSpecs[relayName]?.[paraName])) {
    throw new Error(`Unable to construct light chain ${chain}`);
  }

  const relay = new ScProvider(Sc, relaySpecs[relayName]);

  if (!paraName) {
    return relay;
  }

  // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
  const specMod = await import(`${lightSpecs[relayName][paraName]}`);

  // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
  return new ScProvider(Sc, JSON.stringify(specMod.default), relay);
}

/**
 * @internal
 */
async function createApi (apiUrl: string, signer: ApiSigner, isLocalFork: boolean, onError: (error: unknown) => void): Promise<CreateApiReturn> {
  const types = getDevTypes();
  const isLight = apiUrl.startsWith('light://');
  let provider;
  let chopsticksFork: Blockchain | null = null;

  try {
    if (isLight) {
      provider = await getLightProvider(apiUrl.replace('light://', ''));
    } else if (isLocalFork) {
      provider = await ChopsticksProvider.fromEndpoint(apiUrl);
      chopsticksFork = provider.chain;
      await setStorage(chopsticksFork, {
        System: {
          Account: [
            [['5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'], { data: { free: 5000 * 1e12 }, providers: 1 }]
          ]
        }
      });
    } else {
      provider = new WsProvider(apiUrl);
    }

    statics.api = new ApiPromise({
      provider,
      registry: statics.registry,
      signer,
      types,
      typesBundle
    });

    // See https://github.com/polkadot-js/api/pull/4672#issuecomment-1078843960
    if (isLight) {
      await provider.connect();
    }
  } catch (error) {
    onError(error);
  }

  return { fork: chopsticksFork, types };
}

export function ApiCtxRoot ({ apiUrl, children, isElectron, store: keyringStore }: Props): React.ReactElement<Props> | null {
  const { queuePayload, queueSetTxStatus } = useQueue();
  const [state, setState] = useState<ApiState>(EMPTY_STATE);
  const [isApiConnected, setIsApiConnected] = useState(false);
  const [isApiInitialized, setIsApiInitialized] = useState(false);
  const [apiError, setApiError] = useState<null | string>(null);
  const [extensions, setExtensions] = useState<InjectedExtension[] | undefined>();
  const [isLocalFork] = useState(store.get('localFork') === apiUrl);
  const apiEndpoint = useEndpoint(apiUrl);
  const peopleEndpoint = usePeopleEndpoint(apiEndpoint?.relayName || apiEndpoint?.info);
  const relayUrls = useMemo(
    () => (apiEndpoint?.valueRelay && isNumber(apiEndpoint.paraId) && (apiEndpoint.paraId < 2000))
      ? apiEndpoint.valueRelay
      : null,
    [apiEndpoint]
  );
  const peopleUrls = useMemo(
    () => (peopleEndpoint?.isPeople && !apiEndpoint?.isPeople && peopleEndpoint?.providers && apiEndpoint?.isPeopleForIdentity)
      ? peopleEndpoint.providers
      : null,
    [apiEndpoint, peopleEndpoint]
  );
  const apiRelay = useApiUrl(relayUrls);
  const apiSystemPeople = useApiUrl(peopleUrls);
  const createLink = useMemo(
    () => makeCreateLink(apiUrl, isElectron),
    [apiUrl, isElectron]
  );
  // TODO: Once the people migration is complete for polkadot, we can remove the polkadot check at the end.
  const enableIdentity = apiEndpoint?.isPeople || (isNumber(apiEndpoint?.paraId) && (apiEndpoint?.paraId >= 2000)) || apiEndpoint?.info?.toLowerCase() === 'polkadot' || apiEndpoint?.info?.toLowerCase() === 'paseo';
  // TODO remove `(apiEndpoint?.relayName === 'polkadot' && !apiEndpoint?.isRelay && apiRelay)` once polkadot is migrated over to the people chain for identities.
  const value = useMemo<ApiProps>(
    () => objectSpread({}, state, { api: statics.api, apiEndpoint, apiError, apiIdentity: (apiEndpoint?.relayName === 'polkadot' && !apiEndpoint?.isRelay && apiRelay) || ((apiEndpoint?.isPeopleForIdentity && apiSystemPeople) || statics.api), apiRelay, apiSystemPeople, apiUrl, createLink, enableIdentity, extensions, isApiConnected, isApiInitialized, isElectron, isLocalFork, isWaitingInjected: !extensions }),
    [apiError, createLink, extensions, isApiConnected, isApiInitialized, isElectron, isLocalFork, state, apiEndpoint, apiRelay, apiUrl, apiSystemPeople, enableIdentity]
  );

  // initial initialization
  useEffect((): void => {
    const onError = (error: unknown): void => {
      console.error(error);

      setApiError((error as Error).message);
    };

    createApi(apiUrl, new ApiSigner(statics.registry, queuePayload, queueSetTxStatus), isLocalFork, onError)
      .then(({ fork, types }): void => {
        statics.api.on('connected', () => setIsApiConnected(true));
        statics.api.on('disconnected', () => setIsApiConnected(false));
        statics.api.on('error', onError);
        statics.api.on('ready', (): void => {
          const injectedPromise = web3Enable('polkadot-js/apps');

          injectedPromise
            .then(setExtensions)
            .catch(console.error);

          const urlIsEthereum = !!location.href.includes('keyring-type=ethereum');

          loadOnReady(statics.api, apiEndpoint, fork, injectedPromise, keyringStore, types, urlIsEthereum)
            .then(setState)
            .catch(onError);
        });

        if (isLocalFork) {
          // clear localFork from local storage onMount after api setup
          store.set('localFork', '');
          statics.api.connect()
            .catch(onError);
        }

        setIsApiInitialized(true);
      })
      .catch(onError);
  }, [apiEndpoint, apiUrl, queuePayload, queueSetTxStatus, keyringStore, isLocalFork]);

  if (!value.isApiInitialized) {
    return null;
  }

  return (
    <ApiCtx.Provider value={value}>
      {children}
    </ApiCtx.Provider>
  );
}