Ledger nano support (#624)

* networks

* bump

* wip

* settings checkbox

* add @polkadot/networks and hw-ledger

* bump

* wip

* better error

* cleanup

* icon, cannot change genesis, tests for address and account

* clean workflow with refresh

* bump keyring

* I said bump keyring

* Add siging - unexpected call index

* can sign

* refactor

* refactor useLedger

* better error

* first time flow in a new window

* stray comments

* cleanup

* useless rerendering because of t :|

* Apply suggestions from code review

Co-authored-by: Jaco Greeff <jacogr@gmail.com>

* number casting after select

* Apply suggestions from code review

Co-authored-by: Jaco Greeff <jacogr@gmail.com>

* address comment

* parenthesis

Co-authored-by: Jaco Greeff <jacogr@gmail.com>
This commit is contained in:
Thibaut Sardan 2021-01-21 15:22:28 +01:00 committed by GitHub
parent fa2a6e7636
commit 8d351e06c4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1309 additions and 809 deletions

View File

@ -8,12 +8,12 @@
"packages/*"
],
"resolutions": {
"@polkadot/api": "^3.3.1",
"@polkadot/keyring": "^5.2.3",
"@polkadot/networks": "^5.2.3",
"@polkadot/types": "^3.3.1",
"@polkadot/util": "^5.2.3",
"@polkadot/util-crypto": "^5.2.3",
"@polkadot/api": "^3.4.2-11",
"@polkadot/keyring": "^5.2.4-6",
"@polkadot/networks": "^5.2.4-6",
"@polkadot/types": "^3.4.2-11",
"@polkadot/util": "^5.2.4-6",
"@polkadot/util-crypto": "^5.2.4-6",
"babel-core": "^7.0.0-bridge.0",
"safe-buffer": "^5.2.1",
"typescript": "^4.1.3"

View File

@ -8,10 +8,10 @@
"sideEffects": false,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@polkadot/api": "^3.4.1",
"@polkadot/api": "^3.4.2-11",
"@polkadot/extension-inject": "^0.36.2-6",
"@polkadot/keyring": "^5.2.3",
"@polkadot/keyring": "^5.2.4-6",
"@polkadot/phishing": "^0.3.14",
"@polkadot/ui-keyring": "^0.65.2-4"
"@polkadot/ui-keyring": "0.66.4-0"
}
}

View File

@ -4,7 +4,7 @@
import type { MetadataDef } from '@polkadot/extension-inject/types';
import type { KeyringPair, KeyringPair$Json, KeyringPair$Meta } from '@polkadot/keyring/types';
import type { SubjectInfo } from '@polkadot/ui-keyring/observable/types';
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestAuthorizeApprove, RequestAuthorizeReject, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, ResponseAccountExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types';
import type { AccountJson, AllowedPath, AuthorizeRequest, MessageTypes, MetadataRequest, RequestAccountChangePassword, RequestAccountCreateExternal, RequestAccountCreateHardware, RequestAccountCreateSuri, RequestAccountEdit, RequestAccountExport, RequestAccountForget, RequestAccountShow, RequestAccountTie, RequestAccountValidate, RequestAuthorizeApprove, RequestAuthorizeReject, RequestDeriveCreate, RequestDeriveValidate, RequestJsonRestore, RequestMetadataApprove, RequestMetadataReject, RequestSeedCreate, RequestSeedValidate, RequestSigningApprovePassword, RequestSigningApproveSignature, RequestSigningCancel, RequestSigningIsLocked, RequestTypes, ResponseAccountExport, ResponseAuthorizeList, ResponseDeriveValidate, ResponseJsonGetAccountInfo, ResponseSeedCreate, ResponseSeedValidate, ResponseSigningIsLocked, ResponseType, SigningRequest } from '../types';
import { ALLOWED_PATH, PASSWORD_EXPIRY_MS } from '@polkadot/extension-base/defaults';
import chrome from '@polkadot/extension-inject/chrome';
@ -49,6 +49,12 @@ export default class Extension {
return true;
}
private accountsCreateHardware ({ accountIndex, address, addressOffset, genesisHash, hardwareType, name }: RequestAccountCreateHardware): boolean {
keyring.addHardware(address, hardwareType, { accountIndex, addressOffset, genesisHash, name });
return true;
}
private accountsCreateSuri ({ genesisHash, name, password, suri, type }: RequestAccountCreateSuri): boolean {
keyring.addUri(suri, password, { genesisHash, name }, type);
@ -479,6 +485,9 @@ export default class Extension {
case 'pri(accounts.create.external)':
return this.accountsCreateExternal(request as RequestAccountCreateExternal);
case 'pri(accounts.create.hardware)':
return this.accountsCreateHardware(request as RequestAccountCreateHardware);
case 'pri(accounts.create.suri)':
return this.accountsCreateSuri(request as RequestAccountCreateSuri);

View File

@ -32,6 +32,7 @@ export interface AccountJson extends KeyringPair$Meta {
address: string;
genesisHash?: string | null;
isExternal?: boolean;
isHardware?: boolean;
isHidden?: boolean;
name?: string;
parentAddress?: string;
@ -73,6 +74,7 @@ export interface SigningRequest {
export interface RequestSignatures {
// private/internal requests, i.e. from a popup
'pri(accounts.create.external)': [RequestAccountCreateExternal, boolean];
'pri(accounts.create.hardware)': [RequestAccountCreateHardware, boolean];
'pri(accounts.create.suri)': [RequestAccountCreateSuri, boolean];
'pri(accounts.edit)': [RequestAccountEdit, boolean];
'pri(accounts.export)': [RequestAccountExport, ResponseAccountExport];
@ -176,6 +178,15 @@ export interface RequestAccountCreateSuri {
type?: KeypairType;
}
export interface RequestAccountCreateHardware {
accountIndex: number;
address: string;
addressOffset: number;
genesisHash: string;
hardwareType: string;
name: string;
}
export interface RequestAccountChangePassword {
address: string;
oldPass: string;

View File

@ -1,7 +1,7 @@
// Copyright 2019-2021 @polkadot/extension-base authors & contributors
// SPDX-License-Identifier: Apache-2.0
const ALLOWED_PATH = ['/', '/account/restore-json'] as const;
const ALLOWED_PATH = ['/', '/account/import-ledger', '/account/restore-json'] as const;
const PORT_CONTENT = 'content';
const PHISHING_PAGE_REDIRECT = '/phishing-page-detected';
const PORT_EXTENSION = 'extension';

View File

@ -8,7 +8,7 @@
"sideEffects": false,
"dependencies": {
"@babel/runtime": "^7.12.5",
"@polkadot/networks": "^5.2.3"
"@polkadot/networks": "^5.2.4-6"
},
"peerDependencies": {
"@polkadot/api": "*"

View File

@ -9,8 +9,8 @@
"dependencies": {
"@babel/runtime": "^7.12.5",
"@polkadot/extension-inject": "^0.36.2-6",
"@polkadot/util": "^5.2.3",
"@polkadot/util-crypto": "^5.2.3"
"@polkadot/util": "^5.2.4-6",
"@polkadot/util-crypto": "^5.2.4-6"
},
"peerDependencies": {
"@polkadot/api": "*"

View File

@ -7,16 +7,19 @@
"dependencies": {
"@babel/runtime": "^7.12.5",
"@fortawesome/fontawesome-svg-core": "^1.2.34",
"@fortawesome/free-brands-svg-icons": "^5.15.2",
"@fortawesome/free-regular-svg-icons": "^5.15.2",
"@fortawesome/free-solid-svg-icons": "^5.15.2",
"@fortawesome/react-fontawesome": "^0.1.14",
"@polkadot/extension-base": "^0.36.2-6",
"@polkadot/extension-inject": "^0.36.2-6",
"@polkadot/keyring": "^5.2.3",
"@polkadot/react-identicon": "^0.65.2-4",
"@polkadot/react-qr": "^0.65.2-4",
"@polkadot/util": "^5.2.3",
"@polkadot/util-crypto": "^5.2.3",
"@polkadot/hw-ledger": "^5.2.4-6",
"@polkadot/keyring": "^5.2.4-6",
"@polkadot/networks": "^5.2.4-6",
"@polkadot/react-identicon": "^0.62.2-18",
"@polkadot/react-qr": "^0.62.2-18",
"@polkadot/util": "^5.2.4-6",
"@polkadot/util-crypto": "^5.2.4-6",
"i18next": "^19.8.4",
"react": "^17.0.1",
"react-dom": "^17.0.1",

View File

@ -38,6 +38,7 @@ describe('Account component', () => {
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Derive New Account');
expect(wrapper.find('a.menuItem').at(2).text()).toBe('Export Account');
expect(wrapper.find('a.menuItem').at(3).text()).toBe('Forget Account');
expect(wrapper.find('.genesisSelection').exists()).toBe(true);
});
it('does not show Export option if account is external', () => {
@ -47,6 +48,7 @@ describe('Account component', () => {
expect(wrapper.find('a.menuItem').length).toBe(2);
expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Forget Account');
expect(wrapper.find('.genesisSelection').exists()).toBe(true);
});
it('does not show Derive option if account is of ethereum type', () => {
@ -57,5 +59,16 @@ describe('Account component', () => {
expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Export Account');
expect(wrapper.find('a.menuItem').at(2).text()).toBe('Forget Account');
expect(wrapper.find('.genesisSelection').exists()).toBe(true);
});
it('does not show genesis hash selection dropsown if account is hardware', () => {
wrapper = mountAccountComponent({ isExternal: true, isHardware: true });
wrapper.find('.settings').first().simulate('click');
expect(wrapper.find('a.menuItem').length).toBe(2);
expect(wrapper.find('a.menuItem').at(0).text()).toBe('Rename');
expect(wrapper.find('a.menuItem').at(1).text()).toBe('Forget Account');
expect(wrapper.find('.genesisSelection').exists()).toBe(false);
});
});

View File

@ -24,7 +24,7 @@ interface EditState {
toggleActions: number;
}
function Account ({ address, className, genesisHash, isExternal, isHidden, parentName, suri, type }: Props): React.ReactElement<Props> {
function Account ({ address, className, genesisHash, isExternal, isHardware, isHidden, parentName, suri, type }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const onAction = useContext(ActionContext);
const [{ isEditing, toggleActions }, setEditing] = useState<EditState>({ isEditing: false, toggleActions: 0 });
@ -88,18 +88,22 @@ function Account ({ address, className, genesisHash, isExternal, isHidden, paren
>
{t<string>('Forget Account')}
</Link>
<MenuDivider />
<div className='menuItem'>
<Dropdown
className='inputItem'
label=''
onChange={_onChangeGenesis}
options={genesisOptions}
value={genesisHash || ''}
/>
</div>
{!isHardware && (
<>
<MenuDivider />
<div className='menuItem'>
<Dropdown
className='genesisSelection'
label=''
onChange={_onChangeGenesis}
options={genesisOptions}
value={genesisHash || ''}
/>
</div>
</>
)}
</>
), [_onChangeGenesis, _toggleEdit, address, genesisHash, isExternal, t, type]);
), [_onChangeGenesis, _toggleEdit, address, genesisHash, isExternal, isHardware, t, type]);
return (
<div className={className}>
@ -152,7 +156,7 @@ export default styled(Account)`
min-width: 13rem;
padding: 4px 16px;
.inputItem {
.genesisSelection {
margin: 0;
}
}

View File

@ -28,7 +28,7 @@ function AddAccount ({ className }: Props): React.ReactElement<Props> {
<Header
showAdd
showSettings
text='Add Account'
text={t<string>('Add Account')}
/>
<div className={className}>
<div className='image'>

View File

@ -0,0 +1,178 @@
// Copyright 2019-2021 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { faSync } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
import styled from 'styled-components';
import settings from '@polkadot/ui-settings';
import { ActionContext, Address, Button, ButtonArea, Dropdown, VerticalSpace, Warning } from '../components';
import { useLedger } from '../hooks/useLedger';
import useTranslation from '../hooks/useTranslation';
import { createAccountHardware } from '../messaging';
import { Header } from '../partials';
import { ThemeProps } from '../types';
import ledgerChains from '../util/legerChains';
interface AccOption {
text: string;
value: number;
}
interface NetworkOption {
text: string;
value: string | null;
}
const AVAIL: number[] = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19];
interface Props extends ThemeProps {
className?: string;
}
function ImportLedger ({ className }: Props): React.ReactElement {
const { t } = useTranslation();
const [accountIndex, setAccountIndex] = useState<number>(0);
const [addressOffset, setAddressOffset] = useState<number>(0);
const [error, setError] = useState<string | null>(null);
const [isBusy, setIsBusy] = useState(false);
const [genesis, setGenesis] = useState<string | null>(null);
const onAction = useContext(ActionContext);
const name = useMemo(() => `ledger ${accountIndex}/${addressOffset}`, [accountIndex, addressOffset]);
const { address, error: ledgerError, isLoading: ledgerLoading, isLocked: ledgerLocked, refresh, warning: ledgerWarning } = useLedger(genesis, accountIndex, addressOffset);
useEffect(() => {
if (address) {
settings.set({ ledgerConn: 'webusb' });
}
}, [address]);
const accOps = useRef(AVAIL.map((value): AccOption => ({
text: t('Account type {{index}}', { replace: { index: value } }),
value
})));
const addOps = useRef(AVAIL.map((value): AccOption => ({
text: t('Address index {{index}}', { replace: { index: value } }),
value
})));
const networkOps = useRef(
[{
text: 'Select network',
value: ''
},
...ledgerChains.map(({ displayName, genesisHash }): NetworkOption => ({
text: displayName,
value: genesisHash[0]
}))]
);
const _onSave = useCallback(
() => {
if (address && genesis) {
setIsBusy(true);
createAccountHardware(address, 'ledger', accountIndex, addressOffset, name, genesis)
.then(() => onAction('/'))
.catch((error: Error) => {
console.error(error);
setIsBusy(false);
setError(error.message);
});
}
},
[accountIndex, address, addressOffset, genesis, name, onAction]
);
// select element is returning a string
const _onSetAccountIndex = useCallback((value: number) => setAccountIndex(Number(value)), []);
const _onSetAddressOffset = useCallback((value: number) => setAddressOffset(Number(value)), []);
return (
<>
<Header
showBackArrow
text={t<string>('Import Ledger Account')}
/>
<div className={className}>
<Address
address={address}
genesisHash={genesis}
isExternal
isHardware
name={address ? name : ''}
/>
<Dropdown
className='network'
label={t<string>('Network')}
onChange={setGenesis}
options={networkOps.current}
value={genesis}
/>
{genesis && address && !ledgerError && (
<>
<Dropdown
className='accountType'
label={t<string>('account type')}
onChange={_onSetAccountIndex}
options={accOps.current}
value={accountIndex}
/>
<Dropdown
className='accountIndex'
label={t<string>('address index')}
onChange={_onSetAddressOffset}
options={addOps.current}
value={addressOffset}
/>
</>
)}
{!!ledgerWarning && (
<Warning>
{ledgerWarning}
</Warning>
)}
{(!!error || !!ledgerError) && (
<Warning
isDanger
>
{error || ledgerError}
</Warning>
)}
</div>
<VerticalSpace/>
<ButtonArea>
{ledgerLocked
? (
<Button
isBusy={ledgerLoading || isBusy}
onClick={refresh}
>
<FontAwesomeIcon icon={faSync} />
{t<string>('Refresh')}
</Button>
)
: (
<Button
isBusy={ledgerLoading || isBusy}
isDisabled={!!error || !!ledgerError || !address || !genesis}
onClick={_onSave}
>
{t<string>('Import Account')}
</Button>
)
}
</ButtonArea>
</>
);
}
export default styled(ImportLedger)`
.refreshIcon {
margin-right: 0.3rem;
}
`;

View File

@ -0,0 +1,104 @@
// Copyright 2019-2021 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { ExtrinsicPayload } from '@polkadot/types/interfaces';
import { faSync } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { useCallback, useEffect, useState } from 'react';
import styled from 'styled-components';
import { Button, Warning } from '../../components';
import { useLedger } from '../../hooks/useLedger';
import useTranslation from '../../hooks/useTranslation';
interface Props {
accountIndex?: number;
addressOffset? : number;
className?: string;
error: string | null;
genesisHash?: string;
onSignature?: ({ signature }: { signature: string }) => void;
payload?: ExtrinsicPayload;
setError: (value: string | null) => void;
}
function LedgerSign ({ accountIndex, addressOffset, className, error, genesisHash, onSignature, payload, setError } : Props): React.ReactElement<Props> {
const [isBusy, setIsBusy] = useState(false);
const { t } = useTranslation();
const { error: ledgerError, isLoading: ledgerLoading, isLocked: ledgerLocked, ledger, refresh, warning: ledgerWarning } = useLedger(genesisHash, accountIndex, addressOffset);
useEffect(() => {
if (ledgerError) {
setError(ledgerError);
}
}, [ledgerError, setError]);
const _onRefresh = useCallback(() => {
refresh();
setError(null);
}, [refresh, setError]);
const _onSignLedger = useCallback(
(): void => {
if (!ledger || !payload || !onSignature) {
return;
}
setError(null);
setIsBusy(true);
ledger.sign(payload.toU8a(true), accountIndex, addressOffset)
.then((signature) => {
onSignature(signature);
}).catch((e: Error) => {
setError(e.message);
setIsBusy(false);
});
},
[accountIndex, addressOffset, ledger, onSignature, payload, setError]
);
return (
<div className={className}>
{!!ledgerWarning && (
<Warning>
{ledgerWarning}
</Warning>
)}
{error && (
<Warning isDanger>
{error}
</Warning>
)}
{ledgerLocked
? (
<Button
isBusy={isBusy || ledgerLoading}
onClick={_onRefresh}
>
<FontAwesomeIcon icon={faSync} />
{t<string>('Refresh')}
</Button>
)
: (
<Button
isBusy={isBusy || ledgerLoading}
onClick={_onSignLedger}
>
{t<string>('Sign on Ledger')}
</Button>
)
}
</div>
);
}
export default styled(LedgerSign)`
flex-direction: column;
padding: 6px 24px;
.danger {
margin-bottom: .5rem;
}
`;

View File

@ -13,6 +13,7 @@ import { ActionContext, Address, VerticalSpace } from '../../../components';
import { approveSignSignature } from '../../../messaging';
import Bytes from '../Bytes';
import Extrinsic from '../Extrinsic';
import LedgerSign from '../LedgerSign';
import Qr from '../Qr';
import SignArea from './SignArea';
@ -37,7 +38,7 @@ function isRawPayload (payload: SignerPayloadJSON | SignerPayloadRaw): payload i
return !!(payload as SignerPayloadRaw).data;
}
export default function Request ({ account: { isExternal }, buttonText, isFirst, request, signId, url }: Props): React.ReactElement<Props> | null {
export default function Request ({ account: { accountIndex, addressOffset, isExternal, isHardware }, buttonText, isFirst, request, signId, url }: Props): React.ReactElement<Props> | null {
const onAction = useContext(ActionContext);
const [{ hexBytes, payload }, setData] = useState<Data>({ hexBytes: null, payload: null });
const [error, setError] = useState<string | null>(null);
@ -81,9 +82,10 @@ export default function Request ({ account: { isExternal }, buttonText, isFirst,
address={json.address}
genesisHash={json.genesisHash}
isExternal={isExternal}
isHardware={isHardware}
/>
</div>
{isExternal
{isExternal && !isHardware
? (
<Qr
onSignature={_onSignature}
@ -99,6 +101,17 @@ export default function Request ({ account: { isExternal }, buttonText, isFirst,
/>
)
}
{isHardware && (
<LedgerSign
accountIndex={accountIndex as number || 0}
addressOffset={addressOffset as number || 0}
error={error}
genesisHash={json.genesisHash}
onSignature={_onSignature}
payload={payload}
setError={setError}
/>
)}
<SignArea
buttonText={buttonText}
error={error}

View File

@ -23,6 +23,7 @@ import CreateAccount from './CreateAccount';
import Derive from './Derive';
import Export from './Export';
import Forget from './Forget';
import ImportLedger from './ImportLedger';
import ImportQr from './ImportQr';
import ImportSeed from './ImportSeed';
import Metadata from './Metadata';
@ -135,6 +136,7 @@ export default function Popup (): React.ReactElement {
<Route path='/account/create'>{wrapWithErrorBoundary(<CreateAccount />, 'account-creation')}</Route>
<Route path='/account/forget/:address'>{wrapWithErrorBoundary(<Forget />, 'forget-address')}</Route>
<Route path='/account/export/:address'>{wrapWithErrorBoundary(<Export />, 'export-address')}</Route>
<Route path='/account/import-ledger'>{wrapWithErrorBoundary(<ImportLedger />, 'import-ledger')}</Route>
<Route path='/account/import-qr'>{wrapWithErrorBoundary(<ImportQr />, 'import-qr')}</Route>
<Route path='/account/import-seed'>{wrapWithErrorBoundary(<ImportSeed />, 'import-seed')}</Route>
<Route path='/account/restore-json'>{wrapWithErrorBoundary(<RestoreJson />, 'restore-json')}</Route>

View File

@ -36,13 +36,24 @@ interface AccountTestGenesisJson extends AccountTestJson {
}
const externalAccount = { address: '5EeaoDj4VDk8V6yQngKBaCD5MpJUCHrhYjVhBjgMHXoYon1s', expectedIconTheme: 'polkadot', isExternal: true, name: 'External Account', type: 'sr25519' } as AccountJson;
const hardwareAccount = {
address: 'HDE6uFdw53SwUyfKSsjwZNmS2sziWMPuY6uJhGHcFzLYRaJ',
expectedIconTheme: 'polkadot',
// Kusama genesis hash
genesisHash: '0xb0a8d493285c2df73290dfb7e61f870f17b41801197a149ca93654499ea3dafe',
isExternal: true,
isHardware: true,
name: 'Hardware Account',
type: 'sr25519'
} as AccountJson;
const accounts = [
{ address: '5HSDXAC3qEMkSzZK377sTD1zJhjaPiX5tNWppHx2RQMYkjaJ', expectedIconTheme: 'polkadot', name: 'ECDSA Account', type: 'ecdsa' },
{ address: '5FjgD3Ns2UpnHJPVeRViMhCttuemaRXEqaD8V5z4vxcsUByA', expectedIconTheme: 'polkadot', name: 'Ed Account', type: 'ed25519' },
{ address: '5Ggap6soAPaP5UeNaiJsgqQwdVhhNnm6ez7Ba1w9jJ62LM2Q', expectedIconTheme: 'polkadot', name: 'Parent Sr Account', type: 'sr25519' },
{ address: '0xd5D81CD4236a43F48A983fc5B895975c511f634D', expectedIconTheme: 'ethereum', name: 'Ethereum', type: 'ethereum' },
{ ...externalAccount }
{ ...externalAccount },
{ ...hardwareAccount }
] as AccountTestJson[];
// With Westend genesis Hash
@ -259,6 +270,18 @@ describe('Address', () => {
});
});
describe('Hardware wallet account', () => {
let wrapper: ReactWrapper;
beforeAll(async () => {
wrapper = await getWrapper(hardwareAccount, [], false);
});
it('has a usb icon in front of its name', () => {
expect(wrapper.find('Name').find('FontAwesomeIcon [data-icon="usb"]').exists()).toBe(true);
});
});
describe('Encoding and label based on Metadata', () => {
let wrapper: ReactWrapper;

View File

@ -8,6 +8,7 @@ import type { SettingsStruct } from '@polkadot/ui-settings/types';
import type { KeypairType } from '@polkadot/util-crypto/types';
import type { ThemeProps } from '../types';
import { faUsb } from '@fortawesome/free-brands-svg-icons';
import { faCopy, faEye, faEyeSlash } from '@fortawesome/free-regular-svg-icons';
import { faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@ -38,6 +39,7 @@ export interface Props {
className?: string;
genesisHash?: string | null;
isExternal?: boolean | null;
isHardware?: boolean | null;
isHidden?: boolean;
name?: string | null;
parentName?: string | null;
@ -92,7 +94,7 @@ function recodeAddress (address: string, accounts: AccountWithChildren[], chain:
const ACCOUNTS_SCREEN_HEIGHT = 550;
const defaultRecoded = { account: null, formatted: null, prefix: 42, type: DEFAULT_TYPE };
function Address ({ actions, address, children, className, genesisHash, isExternal, isHidden, name, parentName, suri, toggleActions, type: givenType }: Props): React.ReactElement<Props> {
function Address ({ actions, address, children, className, genesisHash, isExternal, isHardware, isHidden, name, parentName, suri, toggleActions, type: givenType }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { accounts } = useContext(AccountContext);
const settings = useContext(SettingsContext);
@ -155,16 +157,24 @@ function Address ({ actions, address, children, className, genesisHash, isExtern
);
const Name = () => {
const displayName = name || account?.name || t('<unknown>');
const accountName = name || account?.name;
const displayName = accountName || t('<unknown>');
return (
<>
{(account?.isExternal || isExternal) && (
<FontAwesomeIcon
className='externalIcon'
icon={faExternalLinkSquareAlt}
title={t('external account')}
/>
{!!accountName && (account?.isExternal || isExternal) && (
(account?.isHardware || isHardware)
? <FontAwesomeIcon
className='hardwareIcon'
icon={faUsb}
rotation={270}
title={t('hardware wallet account')}
/>
: <FontAwesomeIcon
className='externalIcon'
icon={faExternalLinkSquareAlt}
title={t('external account')}
/>
)}
<span title={displayName}>{displayName}</span>
</>);
@ -335,9 +345,10 @@ export default styled(Address)(({ theme }: ThemeProps) => `
}
}
.externalIcon {
.externalIcon, .hardwareIcon {
margin-right: 0.3rem;
color: ${theme.labelColor}
color: ${theme.labelColor};
width: 0.875em;
}
.identityIcon {

View File

@ -83,6 +83,10 @@ export default styled(Button)(({ isDanger, theme }: Props) => `
top: 0;
}
svg {
margin-right: 0.3rem;
}
&.isBusy {
background: rgba(96,96,96,0.15);

View File

@ -13,13 +13,18 @@ interface Props {
isDanger?: boolean;
isDisabled?: boolean;
onClick?: () => void;
title?: string;
to?: string;
}
function Link ({ children, className = '', isDisabled, onClick, to }: Props): React.ReactElement<Props> {
function Link ({ children, className = '', isDisabled, onClick, title, to }: Props): React.ReactElement<Props> {
if (isDisabled) {
return (
<div className={`${className} isDisabled`}>{children}</div>
<div
className={`${className} isDisabled`}
title={title}>
{children}
</div>
);
}
@ -28,6 +33,7 @@ function Link ({ children, className = '', isDisabled, onClick, to }: Props): Re
<RouterLink
className={className}
onClick={onClick}
title={title}
to={to}
>
{children}
@ -38,6 +44,7 @@ function Link ({ children, className = '', isDisabled, onClick, to }: Props): Re
className={className}
href='#'
onClick={onClick}
title={title}
>
{children}
</a>

View File

@ -29,7 +29,7 @@ export default styled(Menu)(({ theme }: ThemeProps) => `
border: 1px solid ${theme.boxBorderColor};
box-sizing: border-box;
box-shadow: 0 0 10px ${theme.boxShadow};
margin-top: 90px;
margin-top: 60px;
padding: 16px 0;
position: absolute;
right: 0;

View File

@ -0,0 +1,134 @@
// Copyright 2019-2021 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Ledger } from '@polkadot/hw-ledger';
import { Network } from '@polkadot/networks/types';
import uiSettings from '@polkadot/ui-settings';
import { assert } from '@polkadot/util';
import ledgerChains from '../util/legerChains';
import useTranslation from './useTranslation';
interface StateBase {
isLedgerCapable: boolean;
isLedgerEnabled: boolean;
}
interface State extends StateBase {
address: string | null;
error: string | null;
isLedgerCapable: boolean;
isLedgerEnabled: boolean;
isLoading: boolean;
isLocked: boolean;
ledger: Ledger | null;
refresh: () => void;
warning: string | null;
}
function getNetwork (genesis: string): Network | undefined {
return ledgerChains.find(({ genesisHash }) => genesisHash[0] === genesis);
}
function retrieveLedger (genesis: string): Ledger {
let ledger: Ledger | null = null;
const { isLedgerCapable } = getState();
assert(isLedgerCapable, 'Incompatible browser, only Chrome is supported');
const def = getNetwork(genesis);
assert(def, `Unable to find supported chain for ${genesis}`);
ledger = new Ledger('webusb', def.network);
return ledger;
}
function getState (): StateBase {
const isLedgerCapable = !!(window as unknown as { USB?: unknown }).USB;
return {
isLedgerCapable,
isLedgerEnabled: isLedgerCapable && uiSettings.ledgerConn !== 'none'
};
}
export function useLedger (genesis?: string | null, accountIndex = 0, addressOffset = 0): State {
const [isLoading, setIsLoading] = useState(false);
const [isLocked, setIsLocked] = useState(false);
const [refreshLock, setRefreshLock] = useState(false);
const [warning, setWarning] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [address, setAddress] = useState<string | null>(null);
const { t } = useTranslation();
const ledger = useMemo(() => {
setIsLocked(false);
setRefreshLock(false);
// this trick allows to refresh the ledger on demand
// when it is shown as locked and the user has actually
// unlocked it, which we can't know.
if (refreshLock || genesis) {
if (!genesis) {
return null;
}
return retrieveLedger(genesis);
}
return null;
}, [genesis, refreshLock]);
useEffect(() => {
if (!ledger || !genesis) {
setAddress(null);
return;
}
setIsLoading(true);
setError(null);
setWarning(null);
ledger.getAddress(false, accountIndex, addressOffset)
.then((res) => {
setIsLoading(false);
setAddress(res.address);
}).catch((e: Error) => {
setIsLoading(false);
const { network } = getNetwork(genesis) || { network: 'unknown network' };
const warningMessage = e.message.includes('Code: 26628')
? t<string>('Is your ledger locked?')
: null;
const errorMessage = e.message.includes('App does not seem to be open')
? t<string>('App "{{network}}" does not seem to be open', { replace: { network } })
: e.message;
setIsLocked(true);
setWarning(warningMessage);
setError(t<string>(
'Ledger error: {{errorMessage}}',
{ replace: { errorMessage } }
));
console.error(e);
setAddress(null);
});
// If the dependency array is exhaustive, with t, the translation function, it
// triggers a useless re-render when ledger device is connected.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [accountIndex, addressOffset, genesis, ledger]);
const refresh = useCallback(() => {
setRefreshLock(true);
setError(null);
setWarning(null);
}, []);
return ({ ...getState(), address, error, isLoading, isLocked, ledger, refresh, warning });
}

View File

@ -117,6 +117,10 @@ export async function createAccountExternal (name: string, address: string, gene
return sendMessage('pri(accounts.create.external)', { address, genesisHash, name });
}
export async function createAccountHardware (address: string, hardwareType: string, accountIndex: number, addressOffset: number, name: string, genesisHash: string): Promise<boolean> {
return sendMessage('pri(accounts.create.hardware)', { accountIndex, address, addressOffset, genesisHash, hardwareType, name });
}
export async function createAccountSuri (name: string, password: string, suri: string, type?: KeypairType): Promise<boolean> {
return sendMessage('pri(accounts.create.suri)', { name, password, suri, type });
}

View File

@ -3,6 +3,7 @@
import type { ThemeProps } from '../types';
import { faUsb } from '@fortawesome/free-brands-svg-icons';
import { faCodeBranch, faFileUpload, faKey, faPlusCircle, faQrcode } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import React, { useCallback, useContext } from 'react';
@ -10,6 +11,7 @@ import styled from 'styled-components';
import { AccountContext, Link, MediaContext, Menu, MenuDivider, MenuItem } from '../components';
import useIsPopup from '../hooks/useIsPopup';
import { useLedger } from '../hooks/useLedger';
import useTranslation from '../hooks/useTranslation';
import { windowOpen } from '../messaging';
@ -19,16 +21,23 @@ interface Props extends ThemeProps {
}
const jsonPath = '/account/restore-json';
const ledgerPath = '/account/import-ledger';
function MenuAdd ({ className, reference }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { master } = useContext(AccountContext);
const mediaAllowed = useContext(MediaContext);
const { isLedgerCapable, isLedgerEnabled } = useLedger();
const isPopup = useIsPopup();
const _openJson = useCallback((): void => {
windowOpen(jsonPath).catch(console.error);
}, []);
const _openJson = useCallback(
() => windowOpen(jsonPath)
, []);
const _onOpenLedgerConnect = useCallback(
() => windowOpen(ledgerPath),
[]
);
return (
<Menu
@ -72,12 +81,38 @@ function MenuAdd ({ className, reference }: Props): React.ReactElement<Props> {
<MenuItem className='menuItem'>
<Link
isDisabled={!mediaAllowed}
title={ !mediaAllowed
? t<string>('Camera access must be first enabled in the settings')
: ''
}
to='/account/import-qr'
>
<FontAwesomeIcon icon={faQrcode} />
<span>{t<string>('Attach external QR-signer account')}</span>
</Link>
</MenuItem>
<MenuItem className='menuItem ledger'>
{isLedgerEnabled
? <Link
isDisabled={!isLedgerCapable}
title={ (!isLedgerCapable && t<string>('Ledger devices can only be connected with Chrome browser')) || ''}
to={ledgerPath}
>
<FontAwesomeIcon
icon={faUsb}
rotation={270}
/>
<span>{ t<string>('Attach ledger account')}</span>
</Link>
: <Link onClick={_onOpenLedgerConnect}>
<FontAwesomeIcon
icon={faUsb}
rotation={270}
/>
<span>{ t<string>('Connect Ledger device')}</span>
</Link>
}
</MenuItem>
</Menu>
);
}
@ -102,6 +137,7 @@ export default React.memo(styled(MenuAdd)(({ theme }: Props) => `
.svg-inline--fa {
color: ${theme.iconNeutralColor};
margin-right: 0.3rem;
width: 0.875em;
}
}
`));

View File

@ -102,12 +102,12 @@ function MenuSettings ({ className, reference }: Props): React.ReactElement<Prop
<MenuDivider/>
<MenuItem
className='setting'
title={t<string>('External QR accounts and Access')}
title={t<string>('External accounts and Access')}
>
<Checkbox
checked={camera}
className='checkbox'
label={t<string>('Allow Camera Access')}
className='checkbox camera'
label={t<string>('Allow QR Camera Access')}
onChange={setCamera}
/>
</MenuItem>
@ -183,6 +183,10 @@ export default React.memo(styled(MenuSettings)(({ theme }: Props) => `
font-size: 15px;
margin-bottom: 0;
&.ledger {
margin-top: 0.2rem;
}
label {
color: ${theme.textColor};
}

View File

@ -0,0 +1,6 @@
// Copyright 2019-2021 @polkadot/extension-ui authors & contributors
// SPDX-License-Identifier: Apache-2.0
import networks from '@polkadot/networks';
export default networks.filter((network) => network.hasLedgerSupport);

1442
yarn.lock

File diff suppressed because it is too large Load Diff