feat: replace the Parity Signer QR flow with "track an address"
Keeps watch-only accounts, drops the protocol that cannot work. ImportQr scanned a Parity Signer QR — SUBSTRATE_ID 0x53, CRYPTO_SR25519 0x01, a crypto-type byte with no ML-DSA value — so no Quantus device could ever answer it, and one branch hardcoded createAccountSuri(..., 'sr25519', ...). But it was also the *only* caller of createAccountExternal, so deleting it would have taken watch-only accounts with it. Those are wanted independently of how the address arrives, and are what the account list is used for. TrackAddress takes a pasted SS58 string instead, which for Quantus is the common case anyway: the address is on screen in another wallet, not on a signing device. It round-trips through decodeAddress/encodeAddress, which rejects a mistyped address by its checksum rather than storing one that can never receive anything, and normalises the prefix so an address pasted from a tool using a different one displays the way the rest of the extension displays it. The signing path had to change with it, and this is the part that would have been a bug. `isExternal && !isHardware` previously rendered the QR signer — and a tracked address satisfies exactly that condition, so leaving it would have shown a Parity QR that nothing can scan, for an account that can never sign. External accounts now show the decoded call and say plainly that the extension holds no key for them. That makes Signing/Qr.tsx unreachable, along with the CMD_MORTAL and CMD_SIGN_MESSAGE Parity command bytes and the _onSignature callback, whose whole job was accepting a signature produced outside the extension. All removed. approveSignSignature in the background is deliberately left. Unlike Ledger, which is gone for good, external signing returns when quantus/extension#10 ports the flow to multipart UR — the message it carries is the right shape for that, and deleting it would only mean writing it again. Two specs rewritten rather than deleted: the one asserting a QR scanner appears for external accounts now asserts the extrinsic and the cannot-sign warning appear instead. Typecheck, lint and 65 tests clean; build:chrome completes. Refs quantus/extension#10, quantus/extension#5 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012uDUodEcRbBwNRi3UCmw8f
This commit is contained in:
@@ -576,7 +576,6 @@ export default class Extension {
|
||||
case 'pri(accounts.create.external)':
|
||||
return this.accountsCreateExternal(request as RequestAccountCreateExternal);
|
||||
|
||||
|
||||
case 'pri(accounts.create.suri)':
|
||||
return this.accountsCreateSuri(request as RequestAccountCreateSuri);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export const MESSAGE_ORIGIN_CONTENT = `${PORT_PREFIX}-content`;
|
||||
|
||||
// '/account/import-ledger' is gone with the Ledger path: no hardware wallet
|
||||
// speaks ML-DSA, so there is nothing for it to connect to. See quantus/extension#5.
|
||||
export const ALLOWED_PATH = ['/', '/account/restore-json'] as const;
|
||||
export const ALLOWED_PATH = ['/', '/account/restore-json', '/account/track-address'] as const;
|
||||
|
||||
export const PASSWORD_EXPIRY_MIN = 15;
|
||||
export const PASSWORD_EXPIRY_MS = PASSWORD_EXPIRY_MIN * 60 * 1000;
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import '@polkadot/extension-mocks/chrome';
|
||||
|
||||
import type { ReactWrapper } from 'enzyme';
|
||||
import type * as _ from '@polkadot/dev-test/globals.d.ts';
|
||||
|
||||
import Adapter from '@wojtekmaj/enzyme-adapter-react-17';
|
||||
import enzyme from 'enzyme';
|
||||
import React from 'react';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { MemoryRouter } from 'react-router';
|
||||
|
||||
import { Button } from '../components/index.js';
|
||||
import * as messaging from '../messaging.js';
|
||||
import { flushAllPromises } from '../testHelpers.js';
|
||||
import ImportQr from './ImportQr.js';
|
||||
|
||||
const { configure, mount } = enzyme;
|
||||
|
||||
const mockedAccount = {
|
||||
content: '12bxf6QJS5hMJgwbJMDjFot1sq93EvgQwyuPWENr9SzJfxtN',
|
||||
expectedBannerChain: 'Polkadot',
|
||||
genesisHash: '0x91b171bb158e2d3848fa23a9f1c25182fb8e20313b2c1eb49219da7a70ce90c3',
|
||||
isAddress: true,
|
||||
name: 'My Polkadot Account'
|
||||
};
|
||||
|
||||
interface ScanType {
|
||||
isAddress: boolean;
|
||||
content: string;
|
||||
genesisHash: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface QrScanAddressProps {
|
||||
className?: string;
|
||||
onError?: (error: Error) => void;
|
||||
onScan: (scanned: ScanType) => void;
|
||||
size?: string | number;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
// // NOTE Required for spyOn when using @swc/jest
|
||||
// // https://github.com/swc-project/swc/issues/3843
|
||||
// jest.mock('../messaging', (): Record<string, unknown> => ({
|
||||
// __esModule: true,
|
||||
// ...jest.requireActual('../messaging')
|
||||
// }));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment,@typescript-eslint/no-unsafe-call
|
||||
configure({ adapter: new Adapter() });
|
||||
|
||||
const typeName = async (wrapper: ReactWrapper, value: string) => {
|
||||
wrapper.find('input').first().simulate('change', { target: { value } });
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
};
|
||||
|
||||
// jest.mock('@polkadot/react-qr', () => {
|
||||
// return {
|
||||
// QrScanAddress: (_: QrScanAddressProps): null => {
|
||||
// return null;
|
||||
// }
|
||||
// };
|
||||
// });
|
||||
|
||||
describe('ImportQr component', () => {
|
||||
let wrapper: ReactWrapper;
|
||||
|
||||
beforeEach(async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
|
||||
wrapper = mount(
|
||||
<MemoryRouter>
|
||||
<ImportQr />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
act(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion
|
||||
(wrapper.find('QrScanAddress').first().prop('onScan') as unknown as QrScanAddressProps['onScan'])(mockedAccount);
|
||||
});
|
||||
await act(flushAllPromises);
|
||||
wrapper.update();
|
||||
});
|
||||
|
||||
describe('Address component', () => {
|
||||
it('shows account as external', () => {
|
||||
expect(wrapper.find('Name').find('FontAwesomeIcon [data-icon="qrcode"]').exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('shows the correct name', () => {
|
||||
expect(wrapper.find('Name span').text()).toEqual(mockedAccount.name);
|
||||
});
|
||||
|
||||
it('shows the correct address', () => {
|
||||
expect(wrapper.find('[data-field="address"]').text()).toEqual(mockedAccount.content);
|
||||
});
|
||||
|
||||
it('shows the correct banner', () => {
|
||||
expect(wrapper.find('[data-field="chain"]').text()).toEqual(mockedAccount.expectedBannerChain);
|
||||
});
|
||||
});
|
||||
|
||||
it('has the button enabled', () => {
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(false);
|
||||
});
|
||||
|
||||
it('displays and error and the button is disabled with a short name', async () => {
|
||||
await typeName(wrapper, 'a');
|
||||
|
||||
expect(wrapper.find('.warning-message').first().text()).toBe('Account name is too short');
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(true);
|
||||
});
|
||||
|
||||
it('has no error message and button enabled with a long name', async () => {
|
||||
const longName = 'aaa';
|
||||
|
||||
await typeName(wrapper, 'a');
|
||||
await typeName(wrapper, longName);
|
||||
|
||||
expect(wrapper.find('.warning-message')).toHaveLength(0);
|
||||
expect(wrapper.find(Button).prop('isDisabled')).toBe(false);
|
||||
expect(wrapper.find('Name span').text()).toEqual(longName);
|
||||
});
|
||||
|
||||
it('shows the external name in the input field', () => {
|
||||
expect(wrapper.find('input').prop('value')).toBe(mockedAccount.name);
|
||||
});
|
||||
|
||||
it('creates the external account', async () => {
|
||||
jest.spyOn(messaging, 'createAccountExternal').mockImplementation(() => Promise.resolve(false));
|
||||
wrapper.find(Button).simulate('click');
|
||||
await act(flushAllPromises);
|
||||
|
||||
expect(messaging.createAccountExternal).toHaveBeenCalledWith(mockedAccount.name, mockedAccount.content, mockedAccount.genesisHash);
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
|
||||
import { QrScanAddress } from '@polkadot/react-qr';
|
||||
|
||||
import AccountNamePasswordCreation from '../components/AccountNamePasswordCreation.js';
|
||||
import { ActionContext, Address, ButtonArea, NextStepButton, VerticalSpace } from '../components/index.js';
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { createAccountExternal, createAccountSuri, createSeed } from '../messaging.js';
|
||||
import { Header, Name } from '../partials/index.js';
|
||||
|
||||
interface QrAccount {
|
||||
content: string;
|
||||
genesisHash: HexString | null;
|
||||
isAddress: boolean;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export default function ImportQr (): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const [account, setAccount] = useState<QrAccount | null>(null);
|
||||
const [address, setAddress] = useState<string | null>(null);
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [password, setPassword] = useState<string | null>(null);
|
||||
|
||||
const _setAccount = useCallback(
|
||||
(qrAccount: QrAccount) => {
|
||||
setAccount(qrAccount);
|
||||
setName(qrAccount?.name || null);
|
||||
|
||||
if (qrAccount.isAddress) {
|
||||
setAddress(qrAccount.content);
|
||||
} else {
|
||||
createSeed(undefined, qrAccount.content)
|
||||
.then(({ address }) => setAddress(address))
|
||||
.catch(console.error);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const _onCreate = useCallback(
|
||||
(): void => {
|
||||
if (account && name) {
|
||||
if (account.isAddress) {
|
||||
createAccountExternal(name, account.content, account.genesisHash)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error: Error) => console.error(error));
|
||||
} else if (password) {
|
||||
createAccountSuri(name, password, account.content, 'sr25519', account.genesisHash)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error: Error) => console.error(error));
|
||||
}
|
||||
}
|
||||
},
|
||||
[account, name, onAction, password]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
showBackArrow
|
||||
text={t('Scan Address Qr')}
|
||||
/>
|
||||
{!account && (
|
||||
<div>
|
||||
<QrScanAddress onScan={_setAccount} />
|
||||
</div>
|
||||
)}
|
||||
{account && (
|
||||
<>
|
||||
<div>
|
||||
<Address
|
||||
{...account}
|
||||
address={address}
|
||||
isExternal={true}
|
||||
name={name}
|
||||
/>
|
||||
</div>
|
||||
{account.isAddress
|
||||
? (
|
||||
<Name
|
||||
isFocused
|
||||
onChange={setName}
|
||||
value={name || ''}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<AccountNamePasswordCreation
|
||||
isBusy={false}
|
||||
onCreate={_onCreate}
|
||||
onNameChange={setName}
|
||||
onPasswordChange={setPassword}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<VerticalSpace />
|
||||
<ButtonArea>
|
||||
<NextStepButton
|
||||
isDisabled={!name || (!account.isAddress && !password)}
|
||||
onClick={_onCreate}
|
||||
>
|
||||
{t('Add the account with identified address')}
|
||||
</NextStepButton>
|
||||
</ButtonArea>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import type { ExtrinsicPayload } from '@polkadot/types/interfaces';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { QrDisplayPayload, QrScanSignature } from '@polkadot/react-qr';
|
||||
import { u8aWrapBytes } from '@polkadot/util';
|
||||
|
||||
import { Button } from '../../components/index.js';
|
||||
import { useTranslation } from '../../hooks/index.js';
|
||||
import { styled } from '../../styled.js';
|
||||
import { CMD_MORTAL, CMD_SIGN_MESSAGE } from './Request/index.js';
|
||||
|
||||
interface Props {
|
||||
address: string;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
cmd: number;
|
||||
genesisHash: string;
|
||||
onSignature: ({ signature }: { signature: HexString }) => void;
|
||||
payload: ExtrinsicPayload | string;
|
||||
|
||||
}
|
||||
|
||||
function Qr ({ address, className, cmd, genesisHash, onSignature, payload }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const [isScanning, setIsScanning] = useState(false);
|
||||
|
||||
const payloadU8a = useMemo(
|
||||
() => {
|
||||
switch (cmd) {
|
||||
case CMD_MORTAL:
|
||||
return (payload as ExtrinsicPayload).toU8a();
|
||||
case CMD_SIGN_MESSAGE:
|
||||
return u8aWrapBytes(payload as string);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[cmd, payload]
|
||||
);
|
||||
|
||||
const _onShowQr = useCallback(
|
||||
() => setIsScanning(true),
|
||||
[]
|
||||
);
|
||||
|
||||
if (!payloadU8a) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className='qrContainer'>
|
||||
Transaction command:{cmd} not supported.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className='qrContainer'>
|
||||
{isScanning
|
||||
? <QrScanSignature onScan={onSignature} />
|
||||
: (
|
||||
<QrDisplayPayload
|
||||
address={address}
|
||||
cmd={cmd}
|
||||
genesisHash={genesisHash}
|
||||
payload={payloadU8a}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{!isScanning && (
|
||||
<Button
|
||||
className='scanButton'
|
||||
onClick={_onShowQr}
|
||||
>
|
||||
{t('Scan signature via camera')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default styled(Qr)<Props>`
|
||||
height: 100%;
|
||||
|
||||
.qrContainer {
|
||||
margin: 5px auto 10px auto;
|
||||
width: 65%;
|
||||
|
||||
img {
|
||||
border: white solid 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.scanButton {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
`;
|
||||
@@ -3,19 +3,16 @@
|
||||
|
||||
import type { AccountJson, RequestSign } from '@polkadot/extension-base/background/types';
|
||||
import type { ExtrinsicPayload } from '@polkadot/types/interfaces';
|
||||
import type { HexString } from '@polkadot/util/types';
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { isExtrinsicRequest } from '@polkadot/extension-base/utils';
|
||||
import { TypeRegistry } from '@polkadot/types';
|
||||
|
||||
import { ActionContext, Address, VerticalSpace, Warning } from '../../../components/index.js';
|
||||
import { Address, VerticalSpace, Warning } from '../../../components/index.js';
|
||||
import { useMetadata, useTranslation } from '../../../hooks/index.js';
|
||||
import { approveSignSignature } from '../../../messaging.js';
|
||||
import Bytes from '../Bytes.js';
|
||||
import Extrinsic from '../Extrinsic.js';
|
||||
import Qr from '../Qr.js';
|
||||
import SignArea from './SignArea.js';
|
||||
|
||||
interface Props {
|
||||
@@ -32,14 +29,10 @@ interface Data {
|
||||
payload: ExtrinsicPayload | null;
|
||||
}
|
||||
|
||||
export const CMD_MORTAL = 2;
|
||||
export const CMD_SIGN_MESSAGE = 3;
|
||||
|
||||
// keep it global, we can and will re-use this across requests
|
||||
const registry = new TypeRegistry();
|
||||
|
||||
export default function Request ({ account: { genesisHash: accountGenesisHash, isExternal, isHardware }, buttonText, isFirst, request, signId, url }: Props): React.ReactElement<Props> | null {
|
||||
const onAction = useContext(ActionContext);
|
||||
export default function Request ({ account: { isExternal, isHardware }, buttonText, isFirst, request, signId, url }: Props): React.ReactElement<Props> | null {
|
||||
const [{ hexBytes, payload }, setData] = useState<Data>({ hexBytes: null, payload: null });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
@@ -77,18 +70,6 @@ export default function Request ({ account: { genesisHash: accountGenesisHash, i
|
||||
}
|
||||
}, [request]);
|
||||
|
||||
const _onSignature = useCallback(
|
||||
({ signature }: { signature: HexString }, signedTransaction?: HexString): void => {
|
||||
approveSignSignature(signId, signature, signedTransaction)
|
||||
.then(() => onAction())
|
||||
.catch((error: Error): void => {
|
||||
setError(error.message);
|
||||
console.error(error);
|
||||
});
|
||||
},
|
||||
[onAction, signId]
|
||||
);
|
||||
|
||||
// Branch on the request itself rather than on the decoded state, so a render
|
||||
// that lands before the effect has caught up shows nothing instead of feeding
|
||||
// the previous request's view a payload of the other shape.
|
||||
@@ -109,24 +90,17 @@ export default function Request ({ account: { genesisHash: accountGenesisHash, i
|
||||
isHardware={isHardware}
|
||||
/>
|
||||
</div>
|
||||
{isExternal && !isHardware
|
||||
? (
|
||||
<Qr
|
||||
address={json.address}
|
||||
cmd={CMD_MORTAL}
|
||||
genesisHash={json.genesisHash}
|
||||
onSignature={_onSignature}
|
||||
payload={payload}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Extrinsic
|
||||
payload={payload}
|
||||
request={json}
|
||||
url={url}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<Extrinsic
|
||||
payload={payload}
|
||||
request={json}
|
||||
url={url}
|
||||
/>
|
||||
{isExternal && (
|
||||
<>
|
||||
<Warning>{t('This account is tracked only — the extension holds no key for it and cannot sign.')}</Warning>
|
||||
<VerticalSpace />
|
||||
</>
|
||||
)}
|
||||
<SignArea
|
||||
buttonText={buttonText}
|
||||
error={error}
|
||||
@@ -152,32 +126,13 @@ export default function Request ({ account: { genesisHash: accountGenesisHash, i
|
||||
isExternal={isExternal}
|
||||
/>
|
||||
</div>
|
||||
{isExternal && !isHardware && accountGenesisHash
|
||||
? (
|
||||
<Qr
|
||||
address={address}
|
||||
cmd={CMD_SIGN_MESSAGE}
|
||||
genesisHash={accountGenesisHash}
|
||||
onSignature={_onSignature}
|
||||
payload={data}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Bytes
|
||||
bytes={data}
|
||||
url={url}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<Bytes
|
||||
bytes={data}
|
||||
url={url}
|
||||
/>
|
||||
<VerticalSpace />
|
||||
{isExternal && !isHardware && !accountGenesisHash && (
|
||||
<>
|
||||
<Warning isDanger>{t('"Allow use on any network" is not supported to show a QR code. You must associate this account with a network.')}</Warning>
|
||||
<VerticalSpace />
|
||||
</>
|
||||
)}
|
||||
{isHardware && <>
|
||||
<Warning>{t('Message signing is not supported for hardware wallets.')}</Warning>
|
||||
{isExternal && <>
|
||||
<Warning>{t('This account is tracked only — the extension holds no key for it and cannot sign.')}</Warning>
|
||||
<VerticalSpace />
|
||||
</>}
|
||||
<SignArea
|
||||
|
||||
@@ -21,7 +21,6 @@ import Request from './Request/index.js';
|
||||
import Extrinsic from './Extrinsic.js';
|
||||
import Signing from './index.js';
|
||||
import { westendMetadata } from './metadataMock.js';
|
||||
import Qr from './Qr.js';
|
||||
import TransactionIndex from './TransactionIndex.js';
|
||||
|
||||
const { configure, mount } = enzyme;
|
||||
@@ -211,7 +210,13 @@ describe('Signing requests', () => {
|
||||
});
|
||||
|
||||
describe('External account', () => {
|
||||
it('shows Qr scanner for external accounts', async () => {
|
||||
// Was "shows Qr scanner for external accounts". That flow round-tripped a
|
||||
// signature through a Parity Signer QR, a protocol no Quantus device speaks
|
||||
// (quantus/extension#10). An external account here is now a tracked address:
|
||||
// the extension holds no key for it and there is no signer to hand off to, so
|
||||
// the right behaviour is to show the call and say so rather than display a
|
||||
// QR nothing can scan.
|
||||
it('shows the extrinsic and a cannot-sign warning for external accounts', async () => {
|
||||
signRequests = [{
|
||||
account: {
|
||||
address: '5Cf1CGZas62RWwce3d2EPqUvSoi1txaXKd9M5w9bEFSsQtRe',
|
||||
@@ -251,8 +256,8 @@ describe('Signing requests', () => {
|
||||
url: 'https://polkadot.js.org/apps/?rpc=wss%3A%2F%2Fwestend-rpc.polkadot.io#/accounts'
|
||||
}];
|
||||
await mountComponent();
|
||||
expect(wrapper.find(Extrinsic)).toHaveLength(0);
|
||||
expect(wrapper.find(Qr)).toHaveLength(1);
|
||||
expect(wrapper.find(Extrinsic)).toHaveLength(1);
|
||||
expect(wrapper.text()).toContain('tracked only');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
135
packages/extension-ui/src/Popup/TrackAddress.tsx
Normal file
135
packages/extension-ui/src/Popup/TrackAddress.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import React, { useCallback, useContext, useState } from 'react';
|
||||
|
||||
import { decodeAddress, encodeAddress } from '@polkadot/util-crypto';
|
||||
|
||||
import { ActionContext, Address, ButtonArea, NextStepButton, VerticalSpace, Warning } from '../components/index.js';
|
||||
import { useTranslation } from '../hooks/index.js';
|
||||
import { createAccountExternal } from '../messaging.js';
|
||||
import { Header, Name } from '../partials/index.js';
|
||||
import { styled } from '../styled.js';
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Replaces ImportQr as the way to add an account the extension holds no key for.
|
||||
//
|
||||
// That flow scanned a Parity Signer QR, a protocol no Quantus device speaks
|
||||
// (quantus/extension#10) — but it was also the *only* caller of
|
||||
// createAccountExternal, so removing it would have taken watch-only accounts with
|
||||
// it. Those are wanted independently of how the address arrives, and for Quantus
|
||||
// pasting is the common case anyway: the address is on screen in another wallet,
|
||||
// not on a signing device.
|
||||
function TrackAddress ({ className }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const onAction = useContext(ActionContext);
|
||||
const [address, setAddress] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
|
||||
const _onChangeAddress = useCallback(
|
||||
(event: React.ChangeEvent<HTMLTextAreaElement>): void => {
|
||||
const value = event.target.value;
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (!trimmed) {
|
||||
setAddress(null);
|
||||
setError(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Round-tripping through decode/encode does two jobs: it rejects a
|
||||
// mistyped address by its checksum rather than storing something that
|
||||
// will never receive anything, and it normalises the prefix so an
|
||||
// address pasted from a tool using a different one is stored the way the
|
||||
// rest of the extension will display it.
|
||||
setAddress(encodeAddress(decodeAddress(trimmed)));
|
||||
setError(null);
|
||||
} catch {
|
||||
setAddress(null);
|
||||
setError(t('This is not a valid address'));
|
||||
}
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const _onCreate = useCallback(
|
||||
(): void => {
|
||||
if (address && name) {
|
||||
// genesisHash null — "any network". A tracked address is just an
|
||||
// address; nothing here knows which chain the holder uses it on.
|
||||
createAccountExternal(name, address, null)
|
||||
.then(() => onAction('/'))
|
||||
.catch((error: Error) => {
|
||||
console.error(error);
|
||||
setError((error).message);
|
||||
});
|
||||
}
|
||||
},
|
||||
[address, name, onAction]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
showBackArrow
|
||||
text={t('Track an address')}
|
||||
/>
|
||||
<div className={className}>
|
||||
<Address
|
||||
address={address}
|
||||
isExternal={true}
|
||||
name={name}
|
||||
/>
|
||||
<textarea
|
||||
autoFocus
|
||||
className='address'
|
||||
onChange={_onChangeAddress}
|
||||
placeholder={t('Paste the address you want to track')}
|
||||
rows={3}
|
||||
/>
|
||||
{error && (
|
||||
<Warning isDanger>{error}</Warning>
|
||||
)}
|
||||
<Name
|
||||
onChange={setName}
|
||||
value={name || ''}
|
||||
/>
|
||||
<Warning>
|
||||
{t('This account has no key in the extension, so it cannot sign. You will see its balance and can use it as a recipient.')}
|
||||
</Warning>
|
||||
</div>
|
||||
<VerticalSpace />
|
||||
<ButtonArea>
|
||||
<NextStepButton
|
||||
isDisabled={!address || !name}
|
||||
onClick={_onCreate}
|
||||
>
|
||||
{t('Add the address')}
|
||||
</NextStepButton>
|
||||
</ButtonArea>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(styled(TrackAddress)<Props>`
|
||||
.address {
|
||||
background: var(--readonlyInputBackground);
|
||||
border-color: var(--inputBorderColor);
|
||||
border-radius: var(--borderRadius);
|
||||
border-style: solid;
|
||||
border-width: 1px;
|
||||
color: var(--textColor);
|
||||
font-family: inherit;
|
||||
font-size: var(--inputLabelFontSize);
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
resize: none;
|
||||
width: 100%;
|
||||
}
|
||||
`);
|
||||
@@ -29,9 +29,9 @@ import AssetHubMigration from './AssetHubMigration.js';
|
||||
import Export from './Export.js';
|
||||
import ExportAll from './ExportAll.js';
|
||||
import Forget from './Forget.js';
|
||||
import ImportQr from './ImportQr.js';
|
||||
import PhishingDetected from './PhishingDetected.js';
|
||||
import RestoreJson from './RestoreJson.js';
|
||||
import TrackAddress from './TrackAddress.js';
|
||||
import Welcome from './Welcome.js';
|
||||
|
||||
const startSettings = settings.get();
|
||||
@@ -160,7 +160,7 @@ export default function Popup (): React.ReactElement {
|
||||
<Route path='/account/forget/:address'>{wrapWithErrorBoundary(<Forget />, 'forget-address')}</Route>
|
||||
<Route path='/account/export/:address'>{wrapWithErrorBoundary(<Export />, 'export-address')}</Route>
|
||||
<Route path='/account/export-all'>{wrapWithErrorBoundary(<ExportAll />, 'export-all-address')}</Route>
|
||||
<Route path='/account/import-qr'>{wrapWithErrorBoundary(<ImportQr />, 'import-qr')}</Route>
|
||||
<Route path='/account/track-address'>{wrapWithErrorBoundary(<TrackAddress />, 'track-address')}</Route>
|
||||
<Route path='/account/import-seed'>{wrapWithErrorBoundary(<ImportSeed />, 'import-seed')}</Route>
|
||||
<Route path='/account/restore-json'>{wrapWithErrorBoundary(<RestoreJson />, 'restore-json')}</Route>
|
||||
<Route path='/account/derive/:address/locked'>{wrapWithErrorBoundary(<Derive isLocked />, 'derived-address-locked')}</Route>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// Copyright 2019-2026 @polkadot/extension-ui authors & contributors
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
import { faCodeBranch, faFileExport, faFileUpload, faKey, faPlusCircle, faQrcode } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCodeBranch, faEye, faFileExport, faFileUpload, faKey, faPlusCircle } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
|
||||
import { AccountContext, Link, MediaContext, Menu, MenuDivider, MenuItem } from '../components/index.js';
|
||||
import { AccountContext, Link, Menu, MenuDivider, MenuItem } from '../components/index.js';
|
||||
import { useIsPopup, useTranslation } from '../hooks/index.js';
|
||||
import { windowOpen } from '../messaging.js';
|
||||
import { styled } from '../styled.js';
|
||||
@@ -20,7 +20,6 @@ const jsonPath = '/account/restore-json';
|
||||
function MenuAdd ({ className, reference }: Props): React.ReactElement<Props> {
|
||||
const { t } = useTranslation();
|
||||
const { master } = useContext(AccountContext);
|
||||
const mediaAllowed = useContext(MediaContext);
|
||||
const isPopup = useIsPopup();
|
||||
|
||||
const _openJson = useCallback(
|
||||
@@ -75,16 +74,9 @@ function MenuAdd ({ className, reference }: Props): React.ReactElement<Props> {
|
||||
</MenuItem>
|
||||
<MenuDivider />
|
||||
<MenuItem className='menuItem'>
|
||||
<Link
|
||||
isDisabled={!mediaAllowed}
|
||||
title={!mediaAllowed
|
||||
? t('Camera access must be first enabled in the settings')
|
||||
: ''
|
||||
}
|
||||
to='/account/import-qr'
|
||||
>
|
||||
<FontAwesomeIcon icon={faQrcode} />
|
||||
<span>{t('Attach external QR-signer account')}</span>
|
||||
<Link to='/account/track-address'>
|
||||
<FontAwesomeIcon icon={faEye} />
|
||||
<span>{t('Track an address')}</span>
|
||||
</Link>
|
||||
</MenuItem>
|
||||
</Menu>
|
||||
|
||||
@@ -82,7 +82,6 @@ function MenuSettings ({ className, reference }: Props): React.ReactElement<Prop
|
||||
}, []
|
||||
);
|
||||
|
||||
|
||||
const _goToAuthList = useCallback(
|
||||
() => {
|
||||
onAction('auth-list');
|
||||
|
||||
@@ -199,5 +199,12 @@
|
||||
"I Understand": "",
|
||||
"Do not teleport balances to the Relay Chain unless:": "",
|
||||
"Address mismatch: derived {{derived}}, expected {{expected}}. Check that the correct Ledger device is connected and the correct app is selected.": "",
|
||||
"Possible cause: the Ledger App setting differs from the app originally used to derive this account.": ""
|
||||
"Possible cause: the Ledger App setting differs from the app originally used to derive this account.": "",
|
||||
"This approval will add the metadata to your extension instance, allowing future requests to be decoded using this metadata.": "",
|
||||
"Track an address": "",
|
||||
"This account is tracked only — the extension holds no key for it and cannot sign.": "",
|
||||
"This is not a valid address": "",
|
||||
"Paste the address you want to track": "",
|
||||
"This account has no key in the extension, so it cannot sign. You will see its balance and can use it as a recipient.": "",
|
||||
"Add the address": ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user