diff --git a/package.json b/package.json index 9072277bcb..9317576eb4 100644 --- a/package.json +++ b/package.json @@ -10,12 +10,12 @@ "packages/*" ], "resolutions": { - "@polkadot/api": "^0.96.0-beta.32", - "@polkadot/api-contract": "^0.96.0-beta.32", - "@polkadot/keyring": "^1.7.0-beta.3", - "@polkadot/types": "^0.96.0-beta.32", - "@polkadot/util": "^1.7.0-beta.3", - "@polkadot/util-crypto": "^1.7.0-beta.3", + "@polkadot/api": "^0.96.0-beta.33", + "@polkadot/api-contract": "^0.96.0-beta.33", + "@polkadot/keyring": "^1.7.0-beta.4", + "@polkadot/types": "^0.96.0-beta.33", + "@polkadot/util": "^1.7.0-beta.4", + "@polkadot/util-crypto": "^1.7.0-beta.4", "babel-core": "^7.0.0-bridge.0", "typescript": "^3.6.4" }, diff --git a/packages/app-accounts/package.json b/packages/app-accounts/package.json index f57be8151e..6ed2b78d5a 100644 --- a/packages/app-accounts/package.json +++ b/packages/app-accounts/package.json @@ -12,7 +12,7 @@ "dependencies": { "@babel/runtime": "^7.6.3", "@polkadot/react-components": "^0.37.0-beta.50", - "@polkadot/react-qr": "^0.47.0-beta.1", + "@polkadot/react-qr": "^0.47.0-beta.2", "@types/file-saver": "^2.0.0", "@types/yargs": "^13.0.2", "babel-plugin-module-resolver": "^3.1.1", diff --git a/packages/app-accounts/src/Account.tsx b/packages/app-accounts/src/Account.tsx index 946068394f..91529679a9 100644 --- a/packages/app-accounts/src/Account.tsx +++ b/packages/app-accounts/src/Account.tsx @@ -6,12 +6,14 @@ import { ActionStatus } from '@polkadot/react-components/Status/types'; import { I18nProps } from '@polkadot/react-components/types'; import React, { useState, useEffect } from 'react'; +import { Popup } from 'semantic-ui-react'; import styled from 'styled-components'; -import { AddressCard, AddressInfo, Button, ChainLock, Forget } from '@polkadot/react-components'; +import { AddressCard, AddressInfo, Button, ChainLock, Forget, Menu } from '@polkadot/react-components'; import keyring from '@polkadot/ui-keyring'; import Backup from './modals/Backup'; import ChangePass from './modals/ChangePass'; +import Derive from './modals/Derive'; import Transfer from './modals/Transfer'; import translate from './translate'; @@ -24,8 +26,10 @@ function Account ({ address, className, t }: Props): React.ReactElement { const [genesisHash, setGenesisHash] = useState(null); const [isBackupOpen, setIsBackupOpen] = useState(false); const [{ isDevelopment, isEditable, isExternal }, setFlags] = useState({ isDevelopment: false, isEditable: false, isExternal: false }); + const [isDeriveOpen, setIsDeriveOpen] = useState(false); const [isForgetOpen, setIsForgetOpen] = useState(false); const [isPasswordOpen, setIsPasswordOpen] = useState(false); + const [isSettingPopupOpen, setIsSettingPopupOpen] = useState(false); const [isTransferOpen, setIsTransferOpen] = useState(false); useEffect((): void => { @@ -40,9 +44,11 @@ function Account ({ address, className, t }: Props): React.ReactElement { }, [address]); const _toggleBackup = (): void => setIsBackupOpen(!isBackupOpen); + const _toggleDerive = (): void => setIsDeriveOpen(!isDeriveOpen); const _toggleForget = (): void => setIsForgetOpen(!isForgetOpen); const _togglePass = (): void => setIsPasswordOpen(!isPasswordOpen); const _toggleTransfer = (): void => setIsTransferOpen(!isTransferOpen); + const _toggleSettingPopup = (): void => setIsSettingPopupOpen(!isSettingPopupOpen); const _onForget = (): void => { if (!address) { return; @@ -112,6 +118,33 @@ function Account ({ address, className, t }: Props): React.ReactElement { size='small' tooltip={t('Send funds from this account')} /> + {isEditable && !isExternal && ( + + } + > + + + {t('Derive account from source')} + + + {t('Change on-chain nickname')} + + + + )} {isEditable && !isExternal && (
@@ -135,9 +168,16 @@ function Account ({ address, className, t }: Props): React.ReactElement { <> {isBackupOpen && ( + )} + {isDeriveOpen && ( + )} {isForgetOpen && ( diff --git a/packages/app-accounts/src/modals/Create.tsx b/packages/app-accounts/src/modals/Create.tsx index df1aea4b81..d8239e5127 100644 --- a/packages/app-accounts/src/modals/Create.tsx +++ b/packages/app-accounts/src/modals/Create.tsx @@ -4,6 +4,7 @@ import { I18nProps } from '@polkadot/react-components/types'; import { ActionStatus } from '@polkadot/react-components/Status/types'; +import { CreateResult } from '@polkadot/ui-keyring/types'; import { KeypairType } from '@polkadot/util-crypto/types'; import { ModalProps } from '../types'; @@ -121,6 +122,34 @@ function updateAddress (seed: string, derivePath: string, seedType: SeedType, pa }; } +export function downloadAccount ({ json, pair }: CreateResult): void { + const blob = new Blob([JSON.stringify(json)], { type: 'application/json; charset=utf-8' }); + + FileSaver.saveAs(blob, `${pair.address}.json`); + InputAddress.setLastValue('account', pair.address); +} + +function createAccount (suri: string, pairType: KeypairType, name: string, password: string, success: string): ActionStatus { + // we will fill in all the details below + const status = { action: 'create' } as ActionStatus; + + try { + const result = keyring.addUri(suri, password, { name: name.trim(), tags: [] }, pairType); + const { address } = result.pair; + + status.account = address; + status.status = 'success'; + status.message = success; + + downloadAccount(result); + } catch (error) { + status.status = 'error'; + status.message = error.message; + } + + return status; +} + function Create ({ className, onClose, onStatusChange, seed: propsSeed, t, type: propsType }: Props): React.ReactElement { const { isDevelopment } = useContext(ApiContext); const [{ address, deriveError, derivePath, isSeedValid, pairType, seed, seedType }, setAddress] = useState(generateSeed(propsSeed, '', propsSeed ? 'raw' : 'bip', propsType)); @@ -150,25 +179,7 @@ function Create ({ className, onClose, onStatusChange, seed: propsSeed, t, type: return; } - // we will fill in all the details below - const status = { action: 'create' } as ActionStatus; - - try { - const { json, pair } = keyring.addUri(`${seed}${derivePath}`, password, { name: name.trim(), tags: [] }, pairType); - const blob = new Blob([JSON.stringify(json)], { type: 'application/json; charset=utf-8' }); - const { address } = pair; - - FileSaver.saveAs(blob, `${address}.json`); - - status.account = address; - status.status = pair ? 'success' : 'error'; - status.message = t('created account'); - - InputAddress.setLastValue('account', address); - } catch (error) { - status.status = 'error'; - status.message = error.message; - } + const status = createAccount(`${seed}${derivePath}`, pairType, name, password, t('created account')); _toggleConfirmation(); onStatusChange(status); diff --git a/packages/app-accounts/src/modals/Derive.tsx b/packages/app-accounts/src/modals/Derive.tsx new file mode 100644 index 0000000000..46ace9b59b --- /dev/null +++ b/packages/app-accounts/src/modals/Derive.tsx @@ -0,0 +1,235 @@ +// Copyright 2017-2019 @polkadot/app-accounts authors & contributors +// This software may be modified and distributed under the terms +// of the Apache-2.0 license. See the LICENSE file for details. + +import { KeyringPair } from '@polkadot/keyring/types'; +import { ActionStatus } from '@polkadot/react-components/Status/types'; +import { I18nProps } from '@polkadot/react-components/types'; +import { KeypairType } from '@polkadot/util-crypto/types'; + +import React, { useContext, useEffect, useState } from 'react'; +import { AddressRow, Button, Input, InputAddress, Modal, Password, StatusContext } from '@polkadot/react-components'; +import { useDebounce } from '@polkadot/react-components/hooks'; +import keyring from '@polkadot/ui-keyring'; +import { keyExtractPath } from '@polkadot/util-crypto'; + +import translate from '../translate'; +import { downloadAccount } from './Create'; +import CreateConfirmation from './CreateConfirmation'; + +interface Props extends I18nProps { + from: string; + onClose: () => void; +} + +interface DerivedAddress { + address: string | null; + deriveError: string | null; +} + +function deriveValidate (suri: string, pairType: KeypairType): string | null { + try { + const { path } = keyExtractPath(suri); + + // we don't allow soft for ed25519 + if (pairType === 'ed25519' && path.some(({ isSoft }): boolean => isSoft)) { + return 'Soft derivation paths are not allowed on ed25519'; + } + } catch (error) { + return error.message; + } + + return null; +} + +function createAccount (source: KeyringPair, suri: string, name: string, password: string, success: string): ActionStatus { + // we will fill in all the details below + const status = { action: 'create' } as ActionStatus; + + try { + const derived = source.derive(suri); + + derived.setMeta({ ...derived.meta, name, tags: [] }); + + const result = keyring.addPair(derived, password || ''); + const { address } = result.pair; + + status.account = address; + status.status = 'success'; + status.message = success; + + downloadAccount(result); + } catch (error) { + status.status = 'error'; + status.message = error.message; + } + + return status; +} + +function Derive ({ className, from, onClose, t }: Props): React.ReactElement { + const { queueAction } = useContext(StatusContext); + const [source] = useState(keyring.getPair(from)); + const [{ address, deriveError }, setDerived] = useState({ address: null, deriveError: null }); + const [isConfirmationOpen, setIsConfirmationOpen] = useState(false); + const [isLocked, setIsLocked] = useState(source.isLocked); + const [{ isNameValid, name }, setName] = useState({ isNameValid: false, name: '' }); + const [{ isPassValid, password }, setPassword] = useState({ isPassValid: false, password: '' }); + const [rootPass, setRootPass] = useState(''); + const [suri, setSuri] = useState(''); + const debouncedSuri = useDebounce(suri); + const isValid = !!address && !deriveError && isNameValid && isPassValid; + + useEffect((): void => { + setIsLocked(source.isLocked); + }, [source]); + + useEffect((): void => { + setDerived((): DerivedAddress => { + let address: string | null = null; + const deriveError = deriveValidate(debouncedSuri, source.type); + + if (!deriveError) { + const result = source.derive(suri); + + address = result.address; + } + + return { address, deriveError }; + }); + }, [debouncedSuri]); + + const _onChangeName = (name: string): void => setName({ isNameValid: !!name.trim(), name }); + const _onChangePass = (password: string): void => setPassword({ isPassValid: keyring.isPassValid(password), password }); + const _toggleConfirmation = (): void => setIsConfirmationOpen(!isConfirmationOpen); + const _onUnlock = (): void => { + try { + source.decodePkcs8(rootPass); + } catch (error) { + console.error(error); + } + + setIsLocked(source.isLocked); + }; + + const _onCommit = (): void => { + if (!isValid) { + return; + } + + const status = createAccount(source, suri, name, password, t('created account')); + + _toggleConfirmation(); + queueAction(status); + onClose(); + }; + + const sourceStatic = ( + + ); + + return ( + + {t('Derive account from pair')} + {address && isConfirmationOpen && ( + + )} + + {isLocked && ( + <> + {sourceStatic} + + + )} + {!isLocked && ( + + {sourceStatic} + /////". The "/" and "//" may be repeated and mixed`. The "///password" is optional and should only occur once.')} + label={t('derivation path')} + onChange={setSuri} + placeholder={t('//hard/soft')} + /> + + + + )} + + + +