polkadot-js/apps

View on GitHub
packages/page-accounts/src/modals/Create.tsx

Summary

Maintainability
D
2 days
Test Coverage
// Copyright 2017-2024 @polkadot/app-accounts authors & contributors
// SPDX-License-Identifier: Apache-2.0

import type { ActionStatus } from '@polkadot/react-components/Status/types';
import type { AddressState, CreateOptions, CreateProps, DeriveValidationOutput, PairType, SeedType } from '../types.js';

import React, { useCallback, useRef, useState } from 'react';

import { DEV_PHRASE } from '@polkadot/keyring/defaults';
import { AddressRow, Button, Checkbox, CopyButton, Dropdown, Expander, Input, MarkError, MarkWarning, Modal, styled, TextArea } from '@polkadot/react-components';
import { useApi, useLedger, useStepper } from '@polkadot/react-hooks';
import { keyring } from '@polkadot/ui-keyring';
import { settings } from '@polkadot/ui-settings';
import { isHex, nextTick, u8aToHex } from '@polkadot/util';
import { hdLedger, hdValidatePath, keyExtractSuri, mnemonicGenerate, mnemonicValidate, randomAsU8a } from '@polkadot/util-crypto';

import { useTranslation } from '../translate.js';
import { tryCreateAccount } from '../util.js';
import CreateAccountInputs from './CreateAccountInputs.js';
import CreateConfirmation from './CreateConfirmation.js';
import CreateEthDerivationPath, { ETH_DEFAULT_PATH } from './CreateEthDerivationPath.js';
import CreateSuriLedger from './CreateSuriLedger.js';
import ExternalWarning from './ExternalWarning.js';

const DEFAULT_PAIR_TYPE = 'sr25519';
const STEPS_COUNT = 3;

function getSuri (seed: string, derivePath: string, pairType: PairType): string {
  return pairType === 'ed25519-ledger'
    ? u8aToHex(hdLedger(seed, derivePath).secretKey.slice(0, 32))
    : pairType === 'ethereum'
      ? `${seed}/${derivePath}`
      : `${seed}${derivePath}`;
}

function deriveValidate (seed: string, seedType: SeedType, derivePath: string, pairType: PairType): DeriveValidationOutput {
  try {
    const { password, path } = keyExtractSuri(pairType === 'ethereum' ? `${seed}/${derivePath}` : `${seed}${derivePath}`);
    let result: DeriveValidationOutput = {};

    // show a warning in case the password contains an unintended / character
    if (password?.includes('/')) {
      result = { warning: 'WARNING_SLASH_PASSWORD' };
    }

    // we don't allow soft for ed25519
    if (pairType === 'ed25519' && path.some(({ isSoft }): boolean => isSoft)) {
      return { ...result, error: 'SOFT_NOT_ALLOWED' };
    }

    // we don't allow password for hex seed
    if (seedType === 'raw' && password) {
      return { ...result, error: 'PASSWORD_IGNORED' };
    }

    if (pairType === 'ethereum' && !hdValidatePath(derivePath)) {
      return { ...result, error: 'INVALID_DERIVATION_PATH' };
    }

    return result;
  } catch (error) {
    return { error: (error as Error).message };
  }
}

function isHexSeed (seed: string): boolean {
  return isHex(seed) && seed.length === 66;
}

function rawValidate (seed: string): boolean {
  return ((seed.length > 0) && (seed.length <= 32)) || isHexSeed(seed);
}

function addressFromSeed (seed: string, derivePath: string, pairType: PairType): string {
  return keyring
    .createFromUri(getSuri(seed, derivePath, pairType), {}, pairType === 'ed25519-ledger' ? 'ed25519' : pairType)
    .address;
}

function newSeed (seed: string | undefined | null, seedType: SeedType): string {
  switch (seedType) {
    case 'bip':
      return mnemonicGenerate();
    case 'dev':
      return DEV_PHRASE;
    default:
      return seed || u8aToHex(randomAsU8a());
  }
}

function generateSeed (_seed: string | undefined | null, derivePath: string, seedType: SeedType, pairType: PairType = DEFAULT_PAIR_TYPE): AddressState {
  const seed = newSeed(_seed, seedType);
  const address = addressFromSeed(seed, derivePath, pairType);

  return {
    address,
    derivePath,
    deriveValidation: undefined,
    isSeedValid: true,
    pairType,
    seed,
    seedType
  };
}

function updateAddress (seed: string, derivePath: string, seedType: SeedType, pairType: PairType): AddressState {
  let address: string | null = null;
  let deriveValidation: DeriveValidationOutput = deriveValidate(seed, seedType, derivePath, pairType);
  let isSeedValid = false;

  if (seedType === 'raw') {
    isSeedValid = rawValidate(seed);
  } else {
    const words = seed.split(' ');

    if (pairType === 'ed25519-ledger' && words.length === 25) {
      words.pop();

      isSeedValid = mnemonicValidate(words.join(' '));
    } else {
      isSeedValid = mnemonicValidate(seed);
    }
  }

  if (!deriveValidation?.error && isSeedValid) {
    try {
      address = addressFromSeed(seed, derivePath, pairType);
    } catch (error) {
      console.error(error);
      deriveValidation = { error: (error as Error).message ? (error as Error).message : (error as Error).toString() };
      isSeedValid = false;
    }
  }

  return {
    address,
    derivePath,
    deriveValidation,
    isSeedValid,
    pairType,
    seed,
    seedType
  };
}

function createAccount (seed: string, derivePath: string, pairType: PairType, { genesisHash, name, tags = [] }: CreateOptions, password: string, success: string): ActionStatus {
  const commitAccount = () =>
    keyring.addUri(getSuri(seed, derivePath, pairType), password, { genesisHash, isHardware: false, name, tags }, pairType === 'ed25519-ledger' ? 'ed25519' : pairType);

  return tryCreateAccount(commitAccount, success);
}

function Create ({ className = '', onClose, onStatusChange, seed: propsSeed, type: propsType }: CreateProps): React.ReactElement<CreateProps> {
  const { t } = useTranslation();
  const { api, isDevelopment, isEthereum } = useApi();
  const { isLedgerEnabled } = useLedger();
  const [{ address, derivePath, deriveValidation, isSeedValid, pairType, seed, seedType }, setAddress] = useState<AddressState>(() => generateSeed(
    propsSeed,
    isEthereum ? ETH_DEFAULT_PATH : '',
    propsSeed ? 'raw' : 'bip',
    isEthereum ? 'ethereum' : propsType
  ));
  const [isMnemonicSaved, setIsMnemonicSaved] = useState<boolean>(false);
  const [step, nextStep, prevStep] = useStepper();
  const [isBusy, setIsBusy] = useState(false);
  const [{ isNameValid, name }, setName] = useState(() => ({ isNameValid: false, name: '' }));
  const [{ isPasswordValid, password }, setPassword] = useState(() => ({ isPasswordValid: false, password: '' }));
  const isFirstStepValid = !!address && isMnemonicSaved && !deriveValidation?.error && isSeedValid;
  const isSecondStepValid = isNameValid && isPasswordValid;
  const isValid = isFirstStepValid && isSecondStepValid;

  const errorIndex = useRef<Record<string, string>>({
    INVALID_DERIVATION_PATH: t('This is an invalid derivation path.'),
    PASSWORD_IGNORED: t('Password are ignored for hex seed'),
    SOFT_NOT_ALLOWED: t('Soft derivation paths are not allowed on ed25519'),
    WARNING_SLASH_PASSWORD: t('Your password contains at least one "/" character. Disregard this warning if it is intended.')
  });

  const seedOpt = useRef((
    isDevelopment
      ? [{ text: t('Development'), value: 'dev' }]
      : []
  ).concat(
    { text: t('Mnemonic'), value: 'bip' },
    isEthereum
      ? { text: t('Private Key'), value: 'raw' }
      : { text: t('Raw seed'), value: 'raw' }
  ));

  const _onChangePath = useCallback(
    (newDerivePath: string) => setAddress(
      updateAddress(seed, newDerivePath, seedType, pairType)
    ),
    [pairType, seed, seedType]
  );

  const _onChangeSeed = useCallback(
    (newSeed: string) => setAddress(
      updateAddress(newSeed, derivePath, seedType, pairType)
    ),
    [derivePath, pairType, seedType]
  );

  const _onChangePairType = useCallback(
    (newPairType: PairType) => setAddress(
      updateAddress(seed, isEthereum ? ETH_DEFAULT_PATH : '', seedType, newPairType)
    ),
    [seed, seedType, isEthereum]
  );

  const _selectSeedType = useCallback(
    (newSeedType: SeedType): void => {
      if (newSeedType !== seedType) {
        setAddress(generateSeed(null, derivePath, newSeedType, pairType));
      }
    },
    [derivePath, pairType, seedType]
  );

  const _toggleMnemonicSaved = useCallback(
    () => setIsMnemonicSaved(!isMnemonicSaved),
    [isMnemonicSaved]
  );

  const _onCommit = useCallback(
    (): void => {
      if (!isValid) {
        return;
      }

      setIsBusy(true);
      nextTick((): void => {
        const options = { genesisHash: isDevelopment ? undefined : api.genesisHash.toHex(), isHardware: false, name: name.trim() };
        const status = createAccount(seed, derivePath, pairType, options, password, t('created account'));

        onStatusChange(status);
        setIsBusy(false);
        onClose();
      });
    },
    [api, derivePath, isDevelopment, isValid, name, onClose, onStatusChange, pairType, password, seed, t]
  );

  return (
    <StyledModal
      className={className}
      header={t('Add an account via seed {{step}}/{{STEPS_COUNT}}', { replace: { STEPS_COUNT, step } })}
      onClose={onClose}
      size='large'
    >
      <Modal.Content>
        <Modal.Columns>
          <AddressRow
            defaultName={name}
            fullLength
            isEditableName={false}
            noDefaultNameOpacity
            value={(isSeedValid && address) || null}
          />
        </Modal.Columns>
        {step === 1 && <>
          <Modal.Columns hint={t('The secret seed value for this account. Ensure that you keep this in a safe place, with access to the seed you can re-create the account.')}>
            <TextArea
              isError={!isSeedValid}
              isReadOnly={seedType === 'dev'}
              label={
                seedType === 'bip'
                  ? t('mnemonic seed')
                  : seedType === 'dev'
                    ? t('development seed')
                    : isEthereum
                      ? t('ethereum private key')
                      : t('seed (hex or string)')
              }
              onChange={_onChangeSeed}
              seed={seed}
              withLabel
            >
              <CopyButton
                className='copyMoved'
                type={seedType === 'bip' ? t('mnemonic') : seedType === 'raw' ? isEthereum ? t('private key') : 'seed' : t('raw seed')}
                value={seed}
              />
              <Dropdown
                defaultValue={seedType}
                isButton
                onChange={_selectSeedType}
                options={seedOpt.current}
              />
            </TextArea>
          </Modal.Columns>
          <Expander
            className='accounts--Creator-advanced'
            isPadded
            summary={t('Advanced creation options')}
          >
            {pairType !== 'ethereum' && (
              <Modal.Columns hint={t('If you are moving accounts between applications, ensure that you use the correct type.')}>
                <Dropdown
                  defaultValue={pairType}
                  label={t('keypair crypto type')}
                  onChange={_onChangePairType}
                  options={
                    isEthereum
                      ? settings.availableCryptosEth
                      : isLedgerEnabled
                        ? settings.availableCryptosLedger
                        : settings.availableCryptos
                  }
                  tabIndex={-1}
                />
              </Modal.Columns>
            )}
            {pairType === 'ed25519-ledger'
              ? (
                <CreateSuriLedger
                  onChange={_onChangePath}
                  seedType={seedType}
                />
              )
              : pairType === 'ethereum'
                ? (
                  <CreateEthDerivationPath
                    derivePath={derivePath}
                    deriveValidation={deriveValidation}
                    onChange={_onChangePath}
                    seed={seed}
                    seedType={seedType}
                  />
                )
                : (
                  <Modal.Columns hint={t('The derivation path allows you to create different accounts from the same base mnemonic.')}>
                    <Input
                      isDisabled={seedType === 'raw'}
                      isError={!!deriveValidation?.error}
                      label={t('secret derivation path')}
                      onChange={_onChangePath}
                      placeholder={
                        seedType === 'raw'
                          ? pairType === 'sr25519'
                            ? t('//hard/soft')
                            : t('//hard')
                          : pairType === 'sr25519'
                            ? t('//hard/soft///password')
                            : t('//hard///password')
                      }
                      tabIndex={-1}
                      value={derivePath}
                    />
                    {deriveValidation?.error && (
                      <MarkError content={errorIndex.current[deriveValidation.error] || deriveValidation.error} />
                    )}
                    {deriveValidation?.warning && (
                      <MarkWarning content={errorIndex.current[deriveValidation.warning]} />
                    )}
                  </Modal.Columns>
                )}
          </Expander>
          <Modal.Columns>
            <ExternalWarning />
            <div className='saveToggle'>
              <Checkbox
                label={<>{t('I have saved my mnemonic seed safely')}</>}
                onChange={_toggleMnemonicSaved}
                value={isMnemonicSaved}
              />
            </div>
          </Modal.Columns>
        </>}
        {step === 2 && <>
          <CreateAccountInputs
            name={{ isNameValid, name }}
            onCommit={_onCommit}
            setName={setName}
            setPassword={setPassword}
          />;
          <Modal.Columns>
            <ExternalWarning />
          </Modal.Columns>
        </>}
        {step === 3 && address && (
          <CreateConfirmation
            derivePath={derivePath}
            isBusy={isBusy}
            pairType={
              pairType === 'ed25519-ledger'
                ? 'ed25519'
                : pairType
            }
            seed={seed}
          />
        )}
      </Modal.Content>
      <Modal.Actions>
        {step === 1 &&
          <Button
            activeOnEnter
            icon='step-forward'
            isDisabled={!isFirstStepValid}
            label={t('Next')}
            onClick={nextStep}
          />
        }
        {step === 2 && (
          <>
            <Button
              icon='step-backward'
              label={t('Prev')}
              onClick={prevStep}
            />
            <Button
              activeOnEnter
              icon='step-forward'
              isDisabled={!isSecondStepValid}
              label={t('Next')}
              onClick={nextStep}
            />
          </>
        )}
        {step === 3 && (
          <>
            <Button
              icon='step-backward'
              label={t('Prev')}
              onClick={prevStep}
            />
            <Button
              activeOnEnter
              icon='plus'
              isBusy={isBusy}
              label={t('Save')}
              onClick={_onCommit}
            />
          </>
        )}
      </Modal.Actions>
    </StyledModal>
  );
}

const StyledModal = styled(Modal)`
  .accounts--Creator-advanced {
    margin-top: 1rem;
    overflow: visible;
  }

  .ui--CopyButton.copyMoved {
    position: absolute;
    right: 9.25rem;
    top: 1.15rem;
  }

  && .TextAreaWithDropdown {
    textarea {
      width: 80%;
    }
    .ui.buttons {
      width: 20%;
    }
  }

  .saveToggle {
    text-align: right;

    .ui--Checkbox {
      margin: 0.8rem 0;

      > label {
        font-weight: var(--font-weight-normal);
      }
    }
  }
`;

export default React.memo(Create);